From 9b2a40781cfda7f6cc2ccd283c5a812d1fa4950a Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 16 Oct 2026 06:22:21 -0400 Subject: [PATCH 001/149] Release 0.14.0 (#1671) Version 0.14.0; pending design-format changes folded into format 25. --- Cargo.lock | 30 +++++++++---------- Cargo.toml | 2 +- .../{pending => v25}/class-a-surfaces.rs | 0 .../feature-pattern-alignment.rs | 0 .../{pending => v25}/flipped-sketch-plane.rs | 0 .../{pending => v25}/infinite-sketch-lines.rs | 0 .../patch-continuity-and-points.rs | 0 .../{pending => v25}/placed-plane-origins.rs | 0 .../sketch-pattern-directions.rs | 0 .../{pending => v25}/split-by-sketch-line.rs | 0 10 files changed, 16 insertions(+), 16 deletions(-) rename crates/doc/src/format/{pending => v25}/class-a-surfaces.rs (100%) rename crates/doc/src/format/{pending => v25}/feature-pattern-alignment.rs (100%) rename crates/doc/src/format/{pending => v25}/flipped-sketch-plane.rs (100%) rename crates/doc/src/format/{pending => v25}/infinite-sketch-lines.rs (100%) rename crates/doc/src/format/{pending => v25}/patch-continuity-and-points.rs (100%) rename crates/doc/src/format/{pending => v25}/placed-plane-origins.rs (100%) rename crates/doc/src/format/{pending => v25}/sketch-pattern-directions.rs (100%) rename crates/doc/src/format/{pending => v25}/split-by-sketch-line.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 88f732ed..1fa23852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4051,7 +4051,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.13.1" +version = "0.14.0" dependencies = [ "block2 0.6.2", "eframe", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "solidcraft-cli" -version = "0.13.1" +version = "0.14.0" dependencies = [ "image", "serde_json", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.13.1" +version = "0.14.0" dependencies = [ "proc-macro2", "quote", @@ -4100,7 +4100,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "serde", @@ -4117,7 +4117,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "serde", @@ -4133,7 +4133,7 @@ dependencies = [ [[package]] name = "solidcraft-geom" -version = "0.13.1" +version = "0.14.0" dependencies = [ "serde", "serde_json", @@ -4142,7 +4142,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "naga", @@ -4159,7 +4159,7 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.13.1" +version = "0.14.0" dependencies = [ "cadmpeg-core", "cadmpeg-ir", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "solidcraft-kernel" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "proptest", @@ -4197,7 +4197,7 @@ dependencies = [ [[package]] name = "solidcraft-mcp" -version = "0.13.1" +version = "0.14.0" dependencies = [ "serde_json", "solidcraft-engine", @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.13.1" +version = "0.14.0" dependencies = [ "image", "moxcms", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "solidcraft-sketch" -version = "0.13.1" +version = "0.14.0" dependencies = [ "serde", "serde_json", @@ -4230,7 +4230,7 @@ dependencies = [ [[package]] name = "solidcraft-ui-egui" -version = "0.13.1" +version = "0.14.0" dependencies = [ "egui", "egui-wgpu", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "solidcraft-web" -version = "0.13.1" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -5750,7 +5750,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.1" +version = "0.14.0" dependencies = [ "serde_json", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index 8955c336..d48e8a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.13.1" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/crates/doc/src/format/pending/class-a-surfaces.rs b/crates/doc/src/format/v25/class-a-surfaces.rs similarity index 100% rename from crates/doc/src/format/pending/class-a-surfaces.rs rename to crates/doc/src/format/v25/class-a-surfaces.rs diff --git a/crates/doc/src/format/pending/feature-pattern-alignment.rs b/crates/doc/src/format/v25/feature-pattern-alignment.rs similarity index 100% rename from crates/doc/src/format/pending/feature-pattern-alignment.rs rename to crates/doc/src/format/v25/feature-pattern-alignment.rs diff --git a/crates/doc/src/format/pending/flipped-sketch-plane.rs b/crates/doc/src/format/v25/flipped-sketch-plane.rs similarity index 100% rename from crates/doc/src/format/pending/flipped-sketch-plane.rs rename to crates/doc/src/format/v25/flipped-sketch-plane.rs diff --git a/crates/doc/src/format/pending/infinite-sketch-lines.rs b/crates/doc/src/format/v25/infinite-sketch-lines.rs similarity index 100% rename from crates/doc/src/format/pending/infinite-sketch-lines.rs rename to crates/doc/src/format/v25/infinite-sketch-lines.rs diff --git a/crates/doc/src/format/pending/patch-continuity-and-points.rs b/crates/doc/src/format/v25/patch-continuity-and-points.rs similarity index 100% rename from crates/doc/src/format/pending/patch-continuity-and-points.rs rename to crates/doc/src/format/v25/patch-continuity-and-points.rs diff --git a/crates/doc/src/format/pending/placed-plane-origins.rs b/crates/doc/src/format/v25/placed-plane-origins.rs similarity index 100% rename from crates/doc/src/format/pending/placed-plane-origins.rs rename to crates/doc/src/format/v25/placed-plane-origins.rs diff --git a/crates/doc/src/format/pending/sketch-pattern-directions.rs b/crates/doc/src/format/v25/sketch-pattern-directions.rs similarity index 100% rename from crates/doc/src/format/pending/sketch-pattern-directions.rs rename to crates/doc/src/format/v25/sketch-pattern-directions.rs diff --git a/crates/doc/src/format/pending/split-by-sketch-line.rs b/crates/doc/src/format/v25/split-by-sketch-line.rs similarity index 100% rename from crates/doc/src/format/pending/split-by-sketch-line.rs rename to crates/doc/src/format/v25/split-by-sketch-line.rs From d5640b5efbd945bbe66b026d0f13ccc28d7fff98 Mon Sep 17 00:00:00 2001 From: phanoob Date: Fri, 16 Oct 2026 12:26:10 +0200 Subject: [PATCH 002/149] Timeline marker, undo and redo compute off the window thread (#1675) Thanks @phanoob. --- crates/engine/src/lib.rs | 21 +++ crates/ui-egui/src/deferred.rs | 123 ++++++++++++++++++ .../ui-egui/src/history_off_thread_tests.rs | 100 ++++++++++++++ crates/ui-egui/src/lib.rs | 19 ++- crates/ui-egui/src/timeline.rs | 12 +- crates/ui-egui/src/timeline_left.rs | 8 +- crates/ui-egui/src/viewport.rs | 13 ++ 7 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/deferred.rs create mode 100644 crates/ui-egui/src/history_off_thread_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c75ba999..d714352c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,6 +156,16 @@ pub struct Snapshot { pub pending_moves: std::collections::BTreeMap, } +/// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]). +pub struct Ahead(Session); + +impl Ahead { + /// Run `commands` (their results stay in the shared evaluation cache). + pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> { + self.0.preview_in_place(commands).map(|_| ()) + } +} + /// What some commands would make, evaluated on a scratch copy of a session /// ([`Session::preview`]). The session itself is not changed. #[derive(Clone, Debug)] @@ -887,6 +897,17 @@ impl Session { self.scratch().preview_in_place(commands).map(|(after, replies)| Preview { before: visible(self), after: visible(&after), replies }) } + /// Run `commands` on a [`Session::scratch`] copy that keeps the undo and redo stacks: what + /// they evaluate lands in the evaluation cache the copy shares with this session, so running + /// them here afterwards finds it there. For a front end that computes a command which may + /// take a while (undo, the history marker moved) off its window thread first. + pub fn ahead(&self) -> Ahead { + let mut copy = self.scratch(); + copy.undo.clone_from(&self.undo); + copy.redo.clone_from(&self.redo); + Ahead(copy) + } + fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<(Self, Vec)> { if commands.is_empty() { return Err(EngineError::Other("nothing to preview".into())); diff --git a/crates/ui-egui/src/deferred.rs b/crates/ui-egui/src/deferred.rs new file mode 100644 index 00000000..382e957e --- /dev/null +++ b/crates/ui-egui/src/deferred.rs @@ -0,0 +1,123 @@ +//! Commands that recompute the history, computed off the window thread as OK is (#1201): the +//! history marker dragged or clicked, a feature suppressed, reordered or deleted from the +//! timeline, undo and redo, the marker put back after a sketch. On a part with a slow feature +//! after the marker (a line of text cut into a block), each of these ran that feature's boolean +//! on the window thread, and the window froze until it was done. +//! +//! The command is evaluated on a copy of the session ([`solidcraft_engine::Session::ahead`]), +//! which leaves what it computes in the evaluation cache the session shares. Done within the +//! app's wait ([`crate::dialogs::APPLY_AT_ONCE`]), the command just runs; otherwise the window +//! goes on, saying it computes, and the command runs for real once the evaluation is in, finding +//! its features in the cache. Commands asked for meanwhile wait their turn; a marker moved again +//! replaces the move still waiting, so a drag computes where it ends rather than every slot it +//! crossed. + +use serde_json::Value; + +use crate::SolveApp; + +/// A command computing off the window thread. +pub struct Deferred { + done: std::sync::mpsc::Receiver<()>, + /// When it started (ms). + pub(crate) started: f64, + command: (String, Value), + /// The design's revision it was computed for: a design changed meanwhile computes it again. + revision: u64, +} + +impl Deferred { + fn finished(&self) -> bool { + !matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)) + } +} + +/// Commands a later one of the same kind replaces while they wait: where the marker ends is all +/// that counts. +fn replaced_by_a_later_one(id: &str) -> bool { + id == "timeline.roll_to" +} + +impl SolveApp { + /// Run a command that may recompute much of the history, off the window thread when it takes + /// a while (see the module notes). `Ok(Null)` when it is left computing or waiting its turn: + /// its outcome then shows as any command's does (an error in the status line). + pub fn run_heavy(&mut self, id: &str, params: Value) -> Result { + if self.deferred.is_some() { + if replaced_by_a_later_one(id) && self.deferred_queue.last().is_some_and(|(q, _)| q == id) { + self.deferred_queue.pop(); + } + self.deferred_queue.push((id.to_string(), params)); + return Ok(Value::Null); + } + match self.begin_deferred(id, ¶ms) { + Some(job) => { + self.deferred = Some(job); + Ok(Value::Null) + } + None => self.run(id, params), + } + } + + /// Since when a command has been computing off the window thread (ms), if one is. + pub fn computing_since(&self) -> Option { + self.deferred.as_ref().map(|d| d.started) + } + + /// Each frame: a command whose evaluation is in runs, then the next one waiting starts. + pub(crate) fn poll_deferred(&mut self, ctx: &egui::Context) { + if let Some(job) = &self.deferred { + if !job.finished() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + if let Some(job) = self.deferred.take() { + let (id, params) = job.command; + let superseded = replaced_by_a_later_one(&id) && self.deferred_queue.first().is_some_and(|(q, _)| *q == id); + if job.revision != self.session.revision { + // The design changed meanwhile: computed again for the design as it is now. + let _ = self.run_heavy(&id, params); + } else if !superseded { + let _ = self.run(&id, params); + } + } + } + if self.deferred.is_none() && !self.deferred_queue.is_empty() { + let (id, params) = self.deferred_queue.remove(0); + let _ = self.run_heavy(&id, params); + } + } + + /// Start evaluating a command ahead. `None` when it should just run: not asked for + /// ([`SolveApp::apply_off_thread`]), a file operation in progress, no threads (the web), or + /// done within the wait. + #[cfg(not(target_arch = "wasm32"))] + fn begin_deferred(&self, id: &str, params: &Value) -> Option { + let wait = self.apply_off_thread?; + if self.file_load.busy() || self.file_picker.is_some() { + return None; + } + let ahead = self.session.ahead(); + let command = (id.to_string(), params.clone()); + let commands = vec![command.clone()]; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("history".into()) + .spawn(move || { + let _ = ahead.evaluate(&commands); + let _ = tx.send(()); + }) + .ok()?; + match rx.recv_timeout(wait) { + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision }) + } + _ => None, + } + } + + #[cfg(target_arch = "wasm32")] + fn begin_deferred(&self, _id: &str, _params: &Value) -> Option { + None + } +} diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs new file mode 100644 index 00000000..99f98a77 --- /dev/null +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -0,0 +1,100 @@ +//! Commands that recompute the history off the window thread (`deferred`): the marker moved, +//! undo, each computed beside the window and run once in; a marker moved again while one move +//! computes replaces the move still waiting. + +use std::time::Duration; + +use serde_json::json; + +use crate::scenario::Harness; + +/// A 60 × 20 × 10 block, a 10 × 10 × 5 boss on it, and a 4 mm hole through the boss, the +/// marker at the end. Every command computes off the window thread (no wait at all). +fn block_boss_hole() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, p) in [ + ("solid.box", json!({"length": 60, "width": 20, "height": 10})), + ("solid.box", json!({"corner": [5, 5, 10], "length": 10, "width": 10, "height": 5, "operation": "join"})), + ("solid.cylinder", json!({"base": [10, 10, -1], "axis": [0, 0, 1], "radius": 2, "height": 20, "operation": "cut"})), + ] { + h.app.run(id, p).unwrap(); + } + h.app.apply_off_thread = Some(Duration::ZERO); + h +} + +/// Frames until nothing computes or waits (a few seconds at most). +fn until_settled(h: &mut Harness) { + for _ in 0..500 { + if h.app.deferred.is_none() && h.app.deferred_queue.is_empty() { + break; + } + h.frames(1); + std::thread::sleep(Duration::from_millis(10)); + } + h.frames(1); +} + +fn marker(h: &Harness) -> Option { + h.app.session.doc.marker +} + +#[test] +fn the_marker_moved_computes_off_the_window_thread_then_moves() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap(); + assert!(h.app.computing_since().is_some(), "computing"); + assert_eq!(marker(&h), None, "the design is as it was while it computes"); + until_settled(&mut h); + assert_eq!(marker(&h), Some(1)); + h.step(&json!({"expect": {"volume": 12000}})).unwrap(); + // And back to the end, the boss and the hole recomputed (or found in the cache). + h.app.run_heavy("timeline.roll_to", json!({})).unwrap(); + until_settled(&mut h); + assert_eq!(marker(&h), None); + let hole = std::f64::consts::PI * 4.0 * 15.0; + let v = h.app.run("inspect.measure", json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); + assert!((v - (12500.0 - hole)).abs() < 1.0, "{v}"); +} + +#[test] +fn a_marker_moved_again_replaces_the_move_still_waiting() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap(); + assert!(h.app.computing_since().is_some()); + // A drag: two more slots while the first computes. Only the last one waits. + h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap(); + h.app.run_heavy("timeline.roll_to", json!({"position": 3})).unwrap(); + assert_eq!(h.app.deferred_queue.len(), 1, "{:?}", h.app.deferred_queue); + until_settled(&mut h); + assert_eq!(marker(&h), None, "where the drag ended: the end of the history"); +} + +#[test] +fn undo_computes_off_the_window_thread() { + let mut h = block_boss_hole(); + let features = h.app.session.doc.features.len(); + h.app.start("edit.undo"); + assert!(h.app.computing_since().is_some(), "undo computing"); + assert_eq!(h.app.session.doc.features.len(), features, "nothing undone yet"); + until_settled(&mut h); + assert_eq!(h.app.session.doc.features.len(), features - 1, "the hole undone"); + h.step(&json!({"expect": {"volume": 12500}})).unwrap(); + h.app.start("edit.redo"); + until_settled(&mut h); + assert_eq!(h.app.session.doc.features.len(), features, "and redone"); +} + +#[test] +fn a_design_changed_while_a_move_computes_is_computed_again() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap(); + assert!(h.app.computing_since().is_some()); + // Something else changes the design meanwhile (a command that just runs). + h.app.run("solid.box", json!({"corner": [40, 5, 10], "length": 5, "width": 5, "height": 5, "operation": "join"})).unwrap(); + let revision = h.app.session.revision; + until_settled(&mut h); + assert!(h.app.session.revision > revision, "the move ran after the change"); + assert_eq!(marker(&h), Some(2)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e5a2a43..60aec6bf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ mod colour_vision_tests; pub mod context_menu; pub mod control; pub mod crash_ui; +pub mod deferred; pub mod delete; pub mod design_properties; pub mod dialogs; @@ -97,6 +98,8 @@ mod expression_complete; mod extrude_edit_tests; mod file_load; mod file_picker; +#[cfg(test)] +mod history_off_thread_tests; pub use file_picker::FilePickerFuture; #[cfg(test)] mod blend_arrow_tests; @@ -321,6 +324,10 @@ pub struct SolveApp { pub preview: preview::PreviewState, /// OK pressed on a feature that takes a while: its evaluation runs off the window thread. pub applying: Option, + /// A command recomputing the history (the marker moved, undo) computing off the window + /// thread, and those asked for meanwhile ([`deferred`]). + pub deferred: Option, + pub deferred_queue: Vec<(String, Value)>, /// How long OK waits for a feature before computing it off the window thread, saying so; /// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it. pub apply_off_thread: Option, @@ -435,6 +442,8 @@ impl SolveApp { slicer_menu: dialogs_save_mesh::SlicerMenu::default(), preview: preview::PreviewState::default(), applying: None, + deferred: None, + deferred_queue: Vec::new(), apply_off_thread: None, last_command: None, esc_handled: false, @@ -843,6 +852,11 @@ impl SolveApp { self.dialog = Some(d); return; } + // Undo and redo can recompute much of the history: off the window thread when slow. + if matches!(id, "edit.undo" | "edit.redo") { + let _ = self.run_heavy(id, json!({})); + return; + } let _ = self.run(id, json!({})); } @@ -928,7 +942,9 @@ impl SolveApp { } fn restore_marker(&mut self, marker: Option) { - let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + // Rolling forward past the features after the sketch recomputes them (an edited sketch + // under a text cut): off the window thread when it takes a while. + let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); } /// Everything shown as selected: the selection plus the open dialog's inputs. @@ -1320,6 +1336,7 @@ impl SolveApp { fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option) { scenario::clear_handles(); + self.poll_deferred(ui.ctx()); toolbar::app_bar(self, ui); issue_report::CadInput::block(self, ui, report_input); file_load::show(self, ui.ctx()); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 4e7fccd2..867f4d83 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -81,7 +81,7 @@ pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec) { } } if with.is_empty() && fail.is_empty() { - let _ = app.run("timeline.delete", json!({ "features": ids })); + let _ = app.run_heavy("timeline.delete", json!({ "features": ids })); } else { app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect())); } @@ -192,7 +192,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { Some(k) if k < n => json!({"position": k}), _ => json!({}), }; - let _ = app.run("timeline.roll_to", pos); + let _ = app.run_heavy("timeline.roll_to", pos); } x += 24.0; } @@ -341,11 +341,11 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { ui.close(); } if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { - let _ = app.run("timeline.suppress", json!({"feature": id})); + let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); ui.close(); } if ui.button(crate::i18n::tr_label("Roll History Marker Here")).clicked() { - let _ = app.run("timeline.roll_to", json!({ "feature": id })); + let _ = app.run_heavy("timeline.roll_to", json!({ "feature": id })); ui.close(); } if ui.button(crate::i18n::tr_label("Find in Browser")).clicked() { @@ -398,14 +398,14 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let slot = slot_at(&xs, end, pp.x); match dg { Drag::Marker => { - let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); + let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); } Drag::Feature(id) => { let from = app.session.doc.feature_index(id).unwrap_or(0); // Dropping after itself means the slot shifts by one. let to = if slot > from { slot - 1 } else { slot }; if to != from { - let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to })); + let _ = app.run_heavy("timeline.reorder", json!({ "feature": id, "position": to })); } } } diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index 51e1f09a..3f88b81a 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -117,7 +117,7 @@ fn timeline_transport(app: &mut SolveApp, ui: &mut egui::Ui) { Some(position) if position < n => json!({"position": position}), _ => json!({}), }; - let _ = app.run("timeline.roll_to", params); + let _ = app.run_heavy("timeline.roll_to", params); } } }); @@ -794,11 +794,11 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { ui.close(); } if ui.button(if *suppressed { "Unsuppress Feature" } else { "Suppress Feature" }).clicked() { - let _ = app.run("timeline.suppress", json!({"feature": id})); + let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); ui.close(); } if ui.button("Roll Back Here").clicked() { - let _ = app.run("timeline.roll_to", json!({"feature": id})); + let _ = app.run_heavy("timeline.roll_to", json!({"feature": id})); ui.close(); } ui.separator(); @@ -875,7 +875,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { { match active { Drag::Marker => { - let _ = app.run("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) }); + let _ = app.run_heavy("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) }); } Drag::Items(items) => { let panel_left = ui.min_rect().left(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 95eb6fda..e5d72eea 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2591,6 +2591,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::select_other::show(app, ui.ctx()); axis_view(app, ui, rect); nav_bar(app, ui, rect); + // A command recomputing the history off the window thread (the marker moved, undo): the + // window goes on, and says so above the status chip. + if let Some(since) = app.computing_since() { + let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); + let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); + let pos = pos2(rect.left() + 12.0, rect.bottom() - 76.0); + let galley = painter.layout_no_wrap(text, FontId::proportional(12.5), t.text); + let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0)); + painter.rect_filled(r, 4.0, t.overlay); + egui::Spinner::new().size(12.0).paint_at(ui, Rect::from_center_size(pos2(r.left() + 13.0, r.center().y), vec2(12.0, 12.0))); + painter.galley(pos + vec2(26.0, 4.0), galley, t.text); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } // Status chip. if let Some((msg, at, err)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { From 3087363c4454e2ead76462f12147d6edf6e278c8 Mon Sep 17 00:00:00 2001 From: Philipp Bischoff <86558944+PhilippBischoff0@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:26:13 +0200 Subject: [PATCH 003/149] test: three ignored tests pass again and run in CI (#679, #562) (#1663) Thanks @PhilippBischoff0. --- apps/solidcraft-cli/tests/action_matrix.rs | 1 - crates/engine/src/cmd/lineage_face_tests.rs | 1 - crates/kernel/src/curvecorner_tests.rs | 5 ++--- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/solidcraft-cli/tests/action_matrix.rs b/apps/solidcraft-cli/tests/action_matrix.rs index ac025a44..35d82d96 100644 --- a/apps/solidcraft-cli/tests/action_matrix.rs +++ b/apps/solidcraft-cli/tests/action_matrix.rs @@ -1519,7 +1519,6 @@ fn sketches_on_loft_and_sweep_faces() { } #[test] -#[ignore = "fails until #562 is fixed"] fn press_pull_on_the_pi_case_floor() { reproduces( Some("tests/action_matrix/designs/pi-case.solidcraft"), diff --git a/crates/engine/src/cmd/lineage_face_tests.rs b/crates/engine/src/cmd/lineage_face_tests.rs index 6eac284e..70224f98 100644 --- a/crates/engine/src/cmd/lineage_face_tests.rs +++ b/crates/engine/src/cmd/lineage_face_tests.rs @@ -309,7 +309,6 @@ fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() { /// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three /// joined down (the inverse elbow). #[test] -#[ignore = "the join of the elbow down fails until #679 is fixed"] fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"})); diff --git a/crates/kernel/src/curvecorner_tests.rs b/crates/kernel/src/curvecorner_tests.rs index 7773a2b9..3153d107 100644 --- a/crates/kernel/src/curvecorner_tests.rs +++ b/crates/kernel/src/curvecorner_tests.rs @@ -244,10 +244,9 @@ fn a_round_ending_in_an_earlier_round_matches_both_together() { } } -/// The back edge rounded first, then the arc's top edge: the arc's round leaves 0.05 mm³ more at -/// the knife-edge tip than both together, or the other order. +/// The back edge rounded first, then the arc's top edge: the same solid as both together, or the +/// other order (the arc's round once left 0.05 mm³ more at the knife-edge tip). #[test] -#[ignore = "not yet: the arc's round after the back edge's leaves 0.05 mm³ more at the tip"] fn the_arc_rounded_after_the_back_edge_matches_both_together() { one_then_the_other(BACK, arc_top()); } From 64b0b674d2d824a346b83b30a3dcc0aee41f6161 Mon Sep 17 00:00:00 2001 From: phanoob Date: Fri, 16 Oct 2026 12:26:19 +0200 Subject: [PATCH 004/149] 2D prism route: the same letters cut again are one exact boolean (#1644) Thanks @phanoob. --- crates/engine/src/cmd/features_more_tests.rs | 29 ++++++++++++ crates/kernel/src/prism.rs | 50 +++++++++++++++++--- tests/robustness/goldens.json | 5 +- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 582ae1fa..7852fc00 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1151,3 +1151,32 @@ fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() { assert_eq!(pieces, 1); assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one)); } + +/// The same line of text cut again, deeper: the new walls lie on the letters' walls. The 2D route +/// takes it as one exact boolean: a point a hair from a segment's end is that end, a sliver of a +/// letter's curve shorter than the tolerance is dropped, and a piece of a letter so short that the +/// engraved walls read it as an arc while the tool has the curve itself is one edge. Before, the +/// sewing found a degenerate edge, then the faces did not close, and the cut went to the B-rep +/// boolean's retries (90 s). +#[test] +fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":40})); + let cut = |s: &mut Session, depth: f64| { + let before = volume(s); + run(s, "sketch.create", json!({"plane":"XY","offset":40})); + run(s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5})); + run(s, "sketch.finish", json!({})); + let id = run(s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{depth}: {r:?}"); + before - volume(s) + }; + let first = cut(&mut s, 1.0); + // Cut again 2 mm deep: one more millimetre of the letters. + let again = cut(&mut s, 2.0); + assert!(rel(again, first) < 1e-3, "{first} {again}"); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 58b9cd80..0101115e 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1300,10 +1300,15 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec near { + for m in k..=j { + let q = (first + m) % n; + pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q))); + } } } else { let t = Tag { end: tag((first + j) % n).end, ..tag(i) }; @@ -1313,6 +1318,9 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec 1e-9 && t < 1.0 - 1e-9; for i in 0..m { @@ -1690,6 +1698,11 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { { return None; } + // A point within `tol` of an end is that end (the sewing welds them into one): a cut + // there would leave a piece with no length. + if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol { + return None; + } let t = seg.closest_param(*p); (t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t) }) @@ -1951,13 +1964,38 @@ fn sew_connected_sections( }; let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z); + // One edge between two ends, whichever way a wall or a cap reads the curve there: a piece of + // a font's curve so short that one section reads it as an arc within the tolerance, where + // another has the curve itself (the same letters cut again), is one edge. The first reading + // stands for all of them. + let readings: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + let same_edge = |spec: EdgeSpec, a: Vec3, b: Vec3, mid: Vec3| -> EdgeSpec { + let mut known = readings.borrow_mut(); + for (ka, kb, km, k) in known.iter() { + if km.dist(mid) <= tol * 10.0 { + if *ka == a && *kb == b { + return k.clone(); + } + if *ka == b && *kb == a { + return k.reversed(); + } + } + } + known.push((a, b, mid, spec.clone())); + spec + }; let edge = |sg: &Seg2, z: f64| -> Option { Some(match sg { Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) }, - Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) }, + Seg2::Arc { .. } => { + let (a, b, mid) = (welded(sg.start(), z), welded(sg.end(), z), at(sg.point_at(0.5), z)); + same_edge(EdgeSpec::Arc { a, b, mid }, a, b, mid) + } _ => { let [p0, p1, p2, p3] = cubic_of(sg)?; - EdgeSpec::Chain { ctrl: same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]).to_vec().into() } + let c = same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]); + let mid = (c[0] + (c[1] + c[2]) * 3.0 + c[3]) * 0.125; + same_edge(EdgeSpec::Chain { ctrl: c.to_vec().into() }, c[0], c[3], mid) } }) }; diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 7b265c10..0d0b7e1b 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -54,7 +54,7 @@ "50": {"area":9387.513708,"bodies":2,"edges":48,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["extruded profile","fillet","join cylinder","join cylinder","fillet","cut box","cut cylinder"],"unsupported":1,"volume":40128.140215}, "51": {"area":4221.874863,"bodies":1,"edges":75,"error":null,"faces":31,"outcome":"ok","rejected":1,"shells":2,"steps":["box","cut box","hole","rectangular pattern","chamfer","join box"],"unsupported":1,"volume":8682.025064}, "52": {"area":8628.608654,"bodies":1,"edges":45,"error":null,"faces":18,"outcome":"ok","rejected":0,"shells":1,"steps":["box","shell","cut cylinder","chamfer","join cylinder"],"unsupported":1,"volume":9001.505742}, - "53": {"area":17168.464938,"bodies":1,"edges":110,"error":null,"faces":46,"outcome":"ok","rejected":1,"shells":4,"steps":["extruded profile","cut cylinder","shell","join cylinder","circular pattern","fillet"],"unsupported":0,"volume":16607.030819}, + "53": {"area":17463.451761,"bodies":1,"edges":116,"error":null,"faces":48,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","cut cylinder","shell","fillet","join cylinder","circular pattern"],"unsupported":0,"volume":16954.551768}, "54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446}, "55": {"area":6796.974648,"bodies":1,"edges":60,"error":"Body","faces":23,"outcome":"invalid","rejected":0,"shells":1,"steps":["box","fillet","cut sphere","circular pattern","rectangular pattern","cut sphere"],"unsupported":0,"volume":24741.239331}, "56": {"area":4453.31728,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","chamfer"],"unsupported":0,"volume":11829.375}, @@ -126,6 +126,7 @@ {"reason":"seed 15 now cuts around a sealed void","seeds":[15]}, {"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]}, {"reason":"a prism cut in a column round the tool (prism_local): seed 65's rectangular pattern (step 4, a second half-cylinder groove overlapping the first, in a block with a rounded vertical edge) is now built by the exact 2D route in a column clear of the round, where the B-rep tries left it with split edges. The step's part has the 10 faces, 24 edges and 16 vertices it should (28 edges and 18 vertices before), and its volume is 45145.383 mm³ against 45145.214 (exactly 45145.103, both within the mesh measure); the generator then picks other edges for its step 7 fillet, so the run ends on another design (45143.48 mm³, 15 faces)","seeds":[65]}, - {"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]} + {"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]}, +{"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]} ] } From 3126962cd42a2d0e723643c22ab365a153611007 Mon Sep 17 00:00:00 2001 From: rlartot <94693724+rlartot@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:26:23 +0200 Subject: [PATCH 005/149] A feature that names its body finds its named face there first (#1637) (#1643) Thanks @rlartot. --- crates/doc/src/eval.rs | 90 +++++++++++++++++++++++++++++- crates/doc/src/feature_geometry.rs | 20 +++++++ crates/doc/src/name_lookup.rs | 19 +++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 12003ecd..45f8ce45 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1171,7 +1171,8 @@ impl Model { b.made = crate::lineage::Made::Before; } let mut warning = None; - // Face references by name: move each face point onto its named face. + // Face references by name: move each face point onto its named face (on the body the + // feature names, when it names one and the face is there). let renamed; // Cylindrical emboss resolves its named face in the selected body's frame // and certifies an analytic anchor; mesh projection here would undo that. @@ -1191,7 +1192,7 @@ impl Model { if n.is_empty() { continue; } - match crate::naming::locate_face(&fst, n, *p) { + match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) { Some(hit) => { if let Some(note) = hit.note(n) { notes.push(note); @@ -5294,3 +5295,88 @@ pub fn sketch_line_plane(st: &ModelState, sketch: u64, line: &str) -> Result ModelState { + let mut st = ModelState::default(); + for i in 0..n { + let x = 20.0 * i as f64; + let cube = kernel::box_solid(Vec3::new(x, 0.0, 0.0), Vec3::new(x + 10.0, 10.0, 10.0)).unwrap(); + st.bodies.push(ModelBody::new(format!("Part{i}"), cube, 1)); + } + st + } + + /// The name of the face of `body` at `p`. + fn name_at(st: &ModelState, body: &str, p: Vec3) -> String { + let b = st.body(body).unwrap(); + let face = crate::appearance::face_index_at(b, p).unwrap(); + crate::naming::face_names(b)[face].clone() + } + + /// Everything a lookup found, to compare two lookups. + fn found(hit: Option) -> String { + format!("{hit:?}") + } + + /// A shell late in a long timeline spent seconds meshing every body of the model to find its + /// face by name (#287). A feature that names its body finds the face there without naming + /// the other bodies, and finds what looking over every body finds. + #[test] + fn a_face_is_found_on_the_body_the_feature_names_without_meshing_the_others() { + let st = parts(12); + let top = Vec3::new(145.0, 5.0, 10.0); + let name = name_at(&st, "Part7", top); + let hit = crate::name_lookup::locate_face_on_body(&st, Some("Part7"), &name, top); + for b in st.bodies.iter().filter(|b| b.name != "Part7") { + assert!(b.mesh.get().is_none(), "{} was meshed", b.name); + } + let h = hit.unwrap(); + assert_eq!((h.body, h.split, h.merged), (7, false, false)); + assert_eq!(found(hit), found(crate::naming::locate_face(&st, &name, top))); + // Looking over every body names (and so meshes) them all. + assert!(st.bodies.iter().all(|b| b.mesh.get().is_some())); + } + + /// A name the named body has no face of (it moved to another body: a combine took the face), + /// a body that is not there, no body, a name saved before format 14 or a name no face has: + /// looked for over every body, as without a body. + #[test] + fn a_name_not_on_the_named_body_is_looked_for_over_every_body() { + let st = parts(4); + let top = Vec3::new(45.0, 5.0, 10.0); + let name = name_at(&st, "Part2", top); + for name in [name.clone(), format!("{}{name}", crate::naming::LEGACY_MARK), "Part2:face:99".into()] { + let everywhere = found(crate::naming::locate_face(&st, &name, top)); + for body in [Some("Part1"), Some("Part2"), Some("Nope"), None] { + assert_eq!(found(crate::name_lookup::locate_face_on_body(&st, body, &name, top)), everywhere, "{name} on {body:?}"); + } + } + let moved = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap(); + assert_eq!((moved.body, moved.split), (2, false)); + } + + /// A copy of a body has its source's face names. Over every body the name finds the face on + /// both, and the one nearest the point is taken as a piece of a split face. A feature on the + /// copy takes the copy's face, whole. + #[test] + fn the_same_face_on_a_copy_is_not_a_piece_of_the_named_bodys_face() { + let mut st = parts(2); + let source = st.bodies[0].clone(); + st.bodies[1].names = crate::naming::NameCell::same(&source); + let top = Vec3::new(25.0, 5.0, 10.0); + let name = name_at(&st, "Part1", top); + assert_eq!(name, name_at(&st, "Part0", Vec3::new(5.0, 5.0, 10.0))); + let on_copy = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap(); + let everywhere = crate::naming::locate_face(&st, &name, top).unwrap(); + assert_eq!((on_copy.body, on_copy.face, on_copy.point), (everywhere.body, everywhere.face, everywhere.point)); + assert_eq!((on_copy.split, everywhere.split), (false, true)); + assert_eq!(on_copy.note(&name), None); + } +} diff --git a/crates/doc/src/feature_geometry.rs b/crates/doc/src/feature_geometry.rs index 0994beeb..09a0a488 100644 --- a/crates/doc/src/feature_geometry.rs +++ b/crates/doc/src/feature_geometry.rs @@ -53,6 +53,26 @@ impl FeatureKind { v } + /// The body a feature that works on one body's faces names (shell, draft, split, offset, + /// delete and replace face, surface offset, the placed plastic features, sheet metal + /// conversion, ruled surface): its face points pick faces of that body. + pub fn face_body(&self) -> Option<&str> { + match self { + FeatureKind::Shell { body, .. } + | FeatureKind::Draft { body, .. } + | FeatureKind::SplitFace { body, .. } + | FeatureKind::OffsetFace { body, .. } + | FeatureKind::DeleteFace { body, .. } + | FeatureKind::ReplaceFace { body, .. } + | FeatureKind::SurfaceOffset { body, .. } + | FeatureKind::Boss { body, .. } + | FeatureKind::SnapFit { body, .. } + | FeatureKind::Rest { body, .. } => body.as_deref(), + FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceRuled { body, .. } => Some(body.as_ref()), + _ => None, + } + } + /// The feature's profile selections, each with its sketch. pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> { let mut v = Vec::new(); diff --git a/crates/doc/src/name_lookup.rs b/crates/doc/src/name_lookup.rs index b65d25da..2b246530 100644 --- a/crates/doc/src/name_lookup.rs +++ b/crates/doc/src/name_lookup.rs @@ -271,6 +271,25 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option { locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p) } +/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a +/// draft…): the one face of that body with the very name, found without working out the names +/// of every other body (a body named from geometry is meshed for it: seconds in a large model). +/// Else as [`locate_face`], over every body: the name moved to another body (a combine took the +/// face), or on this body it names a piece of a split face or a merged one. The same face on +/// another body (a copy of this one) is not a piece of this body's face. +pub(crate) fn locate_face_on_body(st: &ModelState, body: Option<&str>, name: &str, p: Vec3) -> Option { + if legacy_name(name).is_none() + && let Some(i) = body.and_then(|n| st.bodies.iter().position(|b| b.name == n)) + && let Some(b) = st.bodies.get(i).filter(|b| !b.body.is_mesh()) + && let Some(hit) = locate_in(std::iter::once((i, b)), name, p) + && !hit.split + && naming::names(b).faces.get(hit.face).is_some_and(|n| n == name) + { + return Some(hit); + } + locate_face(st, name, p) +} + /// [`locate_face`] among `bodies` (with their indices). fn locate_in<'a>(bodies: impl Iterator, name: &str, p: Vec3) -> Option { if let Some(old) = legacy_name(name) { From bbd4f29ebb58fe4445ccc9aab347d2fbdf8954ce Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:26:27 +0900 Subject: [PATCH 006/149] Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642) Thanks @midasdf. --- ATTRIBUTION.md | 2 + ROADMAP.md | 5 +- crates/doc/src/document.rs | 47 ++ crates/doc/src/eval.rs | 9 + crates/doc/src/eval_surface.rs | 125 +++- .../fillet-corner-and-loft-continuity.rs | 7 + crates/doc/src/tests.rs | 2 + crates/doc/src/walk.rs | 1 + crates/engine/src/cmd/features.rs | 69 ++- crates/engine/src/cmd/sketch_create.rs | 485 ++++++++++++++- crates/engine/src/cmd/sketch_support.rs | 11 +- crates/engine/src/cmd/surface.rs | 114 +++- crates/engine/src/cmd/surface_analysis.rs | 354 ++++++++++- crates/engine/src/lib.rs | 15 + crates/geom/src/blend.rs | 53 ++ crates/geom/src/lib.rs | 4 +- crates/geom/src/profile.rs | 141 ++++- crates/geom/src/tests.rs | 158 +++++ crates/io/src/dxf_out.rs | 2 +- crates/io/src/lib.rs | 2 + crates/kernel/src/blend.rs | 30 +- crates/kernel/src/blend_variable.rs | 87 ++- crates/kernel/src/continuity.rs | 497 ++++++++++++++++ crates/kernel/src/corner_g2.rs | 501 ++++++++++++++++ crates/kernel/src/corner_g2_tests.rs | 154 +++++ crates/kernel/src/curveblend.rs | 491 ++++++++++++++-- crates/kernel/src/curveblend_law_tests.rs | 315 ++++++++++ crates/kernel/src/curvecorner.rs | 84 +++ crates/kernel/src/emboss.rs | 2 +- crates/kernel/src/lib.rs | 10 +- crates/kernel/src/loft_blend.rs | 550 ++++++++++++++++++ crates/kernel/src/loft_blend_tests.rs | 460 +++++++++++++++ crates/kernel/src/native/metadata.rs | 9 +- crates/render/src/curve_display.rs | 4 +- crates/render/src/curve_display_tests.rs | 2 +- crates/sketch/src/curves.rs | 4 +- crates/sketch/src/freeform_offset.rs | 4 +- crates/sketch/src/link.rs | 2 +- crates/sketch/src/tests.rs | 48 +- crates/ui-egui/src/dialogs_surface.rs | 2 +- crates/ui-egui/src/viewport.rs | 23 + docs/screenshots/class-a-blend.png | Bin 0 -> 590527 bytes docs/screenshots/class-a-crown.png | Bin 0 -> 645591 bytes 43 files changed, 4753 insertions(+), 132 deletions(-) create mode 100644 crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs create mode 100644 crates/geom/src/blend.rs create mode 100644 crates/kernel/src/continuity.rs create mode 100644 crates/kernel/src/corner_g2.rs create mode 100644 crates/kernel/src/corner_g2_tests.rs create mode 100644 crates/kernel/src/curveblend_law_tests.rs create mode 100644 crates/kernel/src/loft_blend.rs create mode 100644 crates/kernel/src/loft_blend_tests.rs create mode 100644 docs/screenshots/class-a-blend.png create mode 100644 docs/screenshots/class-a-crown.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 97dca7e5..6221864b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -36,6 +36,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | +| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 | +| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 | | docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 | diff --git a/ROADMAP.md b/ROADMAP.md index 1887bc9c..67e1ba71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,11 +53,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | +| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 21e4125c..97780eb4 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -563,6 +563,10 @@ pub enum FeatureKind { /// What to do when the radius is too large for the geometry around the edges. #[serde(default, skip_serializing_if = "FilletFit::is_exact")] fit: FilletFit, + /// What fills the corners where three blended edges meet: the rolling ball's sphere + /// (default) or a G2 patch repeating each blend up to the second order. + #[serde(default, skip_serializing_if = "FilletCorner::is_rolling")] + corner: FilletCorner, }, Chamfer { edges: Vec, @@ -1311,6 +1315,10 @@ pub enum FeatureKind { /// sketch curves: walls only, no caps. SurfaceLoft { sections: Vec, + #[serde(default = "loft_plain_continuity", skip_serializing_if = "loft_continuity_is_plain")] + continuity: [Continuity; 2], + #[serde(default, skip_serializing_if = "loft_crown_is_plain")] + crown: [f64; 2], }, /// The faces at `faces` copied out at a signed `distance` along their normals as a new /// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces; @@ -1491,6 +1499,23 @@ pub enum FeatureKind { }, } +/// What fills the corner where three blended edges meet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilletCorner { + /// A piece of the rolling ball's sphere: tangent (G1) to each blend. + #[default] + Rolling, + /// A G2 patch repeating each blend's surface up to the second order (see `corner_g2`). + G2, +} + +impl FilletCorner { + pub fn is_rolling(&self) -> bool { + matches!(self, FilletCorner::Rolling) + } +} + /// How Extend continues a surface past its edges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1734,6 +1759,28 @@ pub struct SheetSection { pub profiles: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub curves: Vec, + /// A section picked as an edge chain of a body instead of a sketch: `edges` are points on the + /// chain, and the face the chain bounds is the surface the loft leaves there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edges: Vec, +} + +/// A loft's continuity where a design does not say: both ends G0, the loft of old, which only +/// passes through its sections. +fn loft_plain_continuity() -> [Continuity; 2] { + [Continuity::G0, Continuity::G0] +} + +/// A loft's continuity is plain when it only passes through its sections: both ends G0. +fn loft_continuity_is_plain(c: &[Continuity; 2]) -> bool { + *c == [Continuity::G0, Continuity::G0] +} + +/// A loft's crown is plain when neither end is filled out. +fn loft_crown_is_plain(c: &[f64; 2]) -> bool { + c.iter().all(|v| *v == 0.0) } /// Coil cross-sections. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 45f8ce45..40cde9bd 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4551,8 +4551,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: }; st.blend_edges.insert(f.id, edges.clone()); let input = mb.traced(); + // A G2 corner is built by the corner construction alone, for a constant radius: any + // other style (or a fitted radius) has no corner patch to repeat. + if let FeatureKind::Fillet { corner: crate::FilletCorner::G2, style, fit, .. } = &f.kind + && (!matches!(style, crate::FilletStyle::Constant) || !matches!(fit, crate::FilletFit::Exact)) + { + return Err(DocError::Invalid("a G2 corner is built for a constant-radius fillet (type: constant, fit: exact) only".into())); + } // The blended body, and the lineage of what the last blend started from. let (nb, from) = match &f.kind { + FeatureKind::Fillet { corner: crate::FilletCorner::G2, .. } => (kernel::fillet_g2_corners(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => { @@ -5238,6 +5246,7 @@ mod chain_tests { body: None, style: crate::FilletStyle::Constant, fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index d6c8a573..80917515 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -3,7 +3,7 @@ //! `surface_offset`, `surface_ruled`, `surface_untrim`). use super::*; -use crate::{RuledCorners, RuledMode}; +use crate::{RuledCorners, RuledMode, SheetSection}; use solidcraft_geom::Seg2; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { @@ -313,7 +313,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, push_sheet(st, f, traced); Ok(()) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, continuity, crown } => { + // A loft with edge chains for sections, or one that has to leave its ends a particular + // way, is built through `loft_blend` from the frames of every section; the plain one + // (sketch chains only, both ends G0) is the loft of old. + if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) { + return loft_blend(st, f, sections, *continuity, *crown, doc); + } let mut secs = Vec::with_capacity(sections.len()); let mut sketches = Vec::with_capacity(sections.len()); for sec in sections { @@ -553,3 +559,118 @@ fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result>> { } Ok(chains) } + +/// A multi-section blend: every section as frames, the body ones read off the face their edge +/// chain bounds, and the first and last leaving with `continuity` where the caller asks for it. +fn loft_blend( + st: &mut ModelState, + f: &Feature, + sections: &[SheetSection], + continuity: [kernel::Continuity; 2], + crown: [f64; 2], + doc: &Document, +) -> Result<()> { + /// One section: a chain of sketch curves in its plane, or a body's edge chain. + enum Piece { + Sketch(solidcraft_geom::Plane, Vec), + Body(String, Vec), + } + let mut pieces = Vec::with_capacity(sections.len()); + let mut centres = Vec::with_capacity(sections.len()); + for sec in sections { + if let Some(name) = sec.body.as_deref() { + if sec.edges.is_empty() { + return Err(DocError::Invalid("a loft section picked from a body needs a chain of its edges".into())); + } + let n = sec.edges.len() as f64; + centres.push(sec.edges.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + **p) * (1.0 / n)); + pieces.push(Piece::Body(name.to_string(), crate::plain(&sec.edges))); + continue; + } + let ss = sketch_in_feature_frame( + doc, + st.sketch(sec.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?, + f.component, + )?; + let profiles = match (&sec.profiles, sec.curves.is_empty()) { + (None, true) => Some(&ProfileSel::All), + (p, _) => p.as_ref(), + }; + let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter(); + let (Some(chain), None) = (chains.next(), chains.next()) else { + return Err(DocError::Invalid("each loft section must be one chain: one profile without holes, or curves meeting end to end".into())); + }; + let pts: Vec = chain.iter().flat_map(|s| s.polyline(1e-2)).collect(); + let centre = pts.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + ss.plane.to_world(*p)) * (1.0 / pts.len().max(1) as f64); + centres.push(centre); + pieces.push(Piece::Sketch(ss.plane, chain)); + } + // Each section leaves towards the one after it; the last one leaves back into the blend. + let stations = BLEND_STATIONS; + let last = pieces.len().saturating_sub(1); + let mut frames = Vec::with_capacity(pieces.len()); + for (i, piece) in pieces.iter().enumerate() { + let (from, to) = if i < last { (i, i + 1) } else { (last, last.saturating_sub(1)) }; + let leave = match (centres.get(from), centres.get(to)) { + (Some(a), Some(b)) => *b - *a, + _ => solidcraft_geom::Vec3::ZERO, + }; + match piece { + Piece::Body(name, edges) => { + let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?.body.clone(); + frames.push(kernel::loft_blend::section_frames(&b, edges, leave, stations)?); + } + Piece::Sketch(plane, chain) => frames.push(chain_frames(plane, chain, leave, stations)), + } + } + let body = kernel::loft_blend(&frames, continuity, crown, stations)?; + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::new(name, body, f.id)); + Ok(()) +} + +/// The frames of one sketch chain, sampled by arclength at `stations` stations: a sketch has no +/// face behind it, so the surface leaves along the run with no curvature to repeat there. +fn chain_frames( + plane: &solidcraft_geom::Plane, + chain: &[solidcraft_geom::Seg2], + leave: solidcraft_geom::Vec3, + stations: usize, +) -> Vec { + let mut pts: Vec = Vec::new(); + for seg in chain { + for p in seg.polyline(1e-2) { + if pts.last().is_none_or(|l: &Vec2| (*l - p).len() > 1e-12) { + pts.push(p); + } + } + } + if pts.len() < 2 || stations < 2 { + return Vec::new(); + } + let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum(); + if !(total > 1e-12) { + return Vec::new(); + } + let step = total / (stations - 1) as f64; + let (mut walked, mut k) = (0.0f64, 0usize); + let mut out = Vec::with_capacity(stations); + for i in 0..stations { + let want = step * i as f64; + while k + 1 < pts.len() && walked + (pts[k + 1] - pts[k]).len() < want { + walked += (pts[k + 1] - pts[k]).len(); + k += 1; + } + let (Some(a), Some(b)) = (pts.get(k).copied(), pts.get(k + 1).copied()) else { + if let Some(p) = pts.get(k).copied() { + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + continue; + }; + let span = (b - a).len(); + let t = if span > 1e-12 { ((want - walked) / span).clamp(0.0, 1.0) } else { 0.0 }; + let p = a * (1.0 - t) + b * t; + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + out +} diff --git a/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs b/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs new file mode 100644 index 00000000..d736c40c --- /dev/null +++ b/crates/doc/src/format/pending/fillet-corner-and-loft-continuity.rs @@ -0,0 +1,7 @@ +//! The Class-A surface pass also lets a `Fillet` carry `corner` (`rolling`, the default and what +//! every older design meant, or `g2`: a patch repeating each blend's surface up to the second +//! order) and a `SurfaceLoft` carry `continuity` and `crown` and body edge-chain `sections` (a +//! section picked as a `body` with `edges` instead of a `sketch`). A design without them saves as +//! it always did — a loft without `continuity` reads and writes as `[g0, g0]`, the plain loft it +//! always was — so older designs need no transformation; 0.13.x asks for an update instead of +//! meeting a feature it can't read or dropping a corner style it does not round. diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3dab4b2..9dbaad6e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -113,6 +113,7 @@ fn box_fillet_cut_timeline() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -183,6 +184,7 @@ fn errors_are_reported_not_fatal() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 7c571a1b..66135aaf 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -50,6 +50,7 @@ impl Walk for crate::document::Operation {} impl Walk for solidcraft_kernel::Continuity {} impl Walk for crate::document::Direction {} impl Walk for crate::document::FilletFit {} +impl Walk for crate::document::FilletCorner {} impl Walk for crate::document::MeshData {} impl Walk for crate::document::RevolveConvention {} impl Walk for crate::document::CoilSection {} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 923ac485..ed5843a3 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("fillet") .key("F") - .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) @@ -1051,10 +1051,26 @@ fn fillet(s: &mut Session, p: &Value) -> Result { if !fit.is_exact() && !style.is_constant() { return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius")); } + // What fills the corners where three blended edges meet. + let corner = match str_(p, "corner").map(str::to_ascii_lowercase).as_deref() { + None | Some("rolling") => solidcraft_doc::FilletCorner::Rolling, + Some("g2") => solidcraft_doc::FilletCorner::G2, + Some(o) => return Err(bad(cmd, format!("unknown corner `{o}` (rolling or g2)"))), + }; + if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) { + return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only")); + } let mut out = add_feature( s, p, - FeatureKind::Fillet { edges: edges.into_iter().map(Position).collect(), radius, body: str_(p, "body").map(BodyName::from), style, fit }, + FeatureKind::Fillet { + edges: edges.into_iter().map(Position).collect(), + radius, + body: str_(p, "body").map(BodyName::from), + style, + fit, + corner, + }, )?; // The radii the fillet used, when `fit: reduce` reduced it. if let Some(id) = out.get("feature").and_then(Value::as_u64) @@ -2042,6 +2058,55 @@ mod thin_extrude_tests; #[cfg(test)] #[path = "extrude_convention_tests.rs"] mod extrude_convention_tests; +/// `solid.fillet corner?: rolling|g2`: the default keeps the rolling ball's sphere corner, `g2` +/// is accepted and refuses (item 10's patch is built but not sewn in yet, see `corner_g2`), and an +/// unknown word is refused. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod corner_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: &Value) -> Value { + s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) + } + + /// A 20 mm box with its three edges at one corner, filleted by 4 with `corner`. + fn cornered(corner: Option<&str>) -> (Session, Value) { + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let mut p = json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4}); + if let Some(c) = corner { + p["corner"] = json!(c); + } + let reply = run(&mut s, "solid.fillet", &p); + (s, reply) + } + + #[test] + fn the_default_corner_is_the_rolling_ball_and_g2_refuses_clearly() { + let (default, reply) = cornered(None); + assert_eq!(reply["warning"], json!(null), "{reply}"); + let (rolling, _) = cornered(Some("rolling")); + let ball = default.world_state().body("B").unwrap().body.clone(); + let same = rolling.world_state().body("B").unwrap().body.clone(); + // `corner: rolling` is the default: the same body, to the last bit of its area. + assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area); + assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0); + // `corner: g2`: accepted, and refused with a clear message until the patch sews in. + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let err = + s.execute("solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2"})).unwrap_err().to_string(); + eprintln!("corner g2: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + // An unknown word, and a style the G2 corner has no patch for, are refused as parameters. + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "round"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "type": "chord"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "fit": "reduce"})).is_err()); + } +} #[cfg(test)] #[path = "fillet_connected_rounds_tests.rs"] mod fillet_connected_rounds_tests; diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index c4eebc0d..7b17fe4d 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -88,7 +88,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("blend") .enabled(in_sketch) - .params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())), + .params(concat!( + "a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)", + created_doc!() + )), CommandSpec::new("sketch.centerline", "Centerline", centerline) .at("SKETCH", "CREATE") .icon("centerline") @@ -399,8 +402,68 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> { sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d))) } +/// The continuity a blend asks for: the number of derivatives of each reference curve it +/// matches at both joints (g1 is the tangent construction the tool has always made). +fn blend_order(p: &Value, cmd: &str) -> Result { + match p.get("continuity") { + None | Some(Value::Null) => Ok(1), + Some(Value::String(v)) => match v.trim().to_ascii_lowercase().as_str() { + "" | "g1" => Ok(1), + "g2" => Ok(2), + "g3" => Ok(3), + "g4" => Ok(4), + _ => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + }, + Some(_) => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + } +} + +/// Negate the odd-order derivatives: how a curve's derivatives change when it is read backwards. +fn flip_odd(d: &mut [Vec2]) { + for (i, v) in d.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } +} + +/// Derivatives 1…`order` of curve `ci`'s continuation through its end point `pi`, in the +/// parameterisation the blend needs at that joint: at the blend's start (`leaving`) the curve's +/// continuation runs away from it, at the blend's end the blend runs into the curve instead, which +/// is the same curve read backwards. The derivatives are those of the one exact piece that ends +/// at `pi`, evaluated at that piece's own end, so an arc gives its circular-motion terms (zero +/// curvature rate included) and a conic the quotient rule's terms. Errors when the curve has no +/// exact piece, when a derivative is not finite (a corrupt or degenerate reference), or when it +/// has no tangent at all: no blend can meet the last case. +fn joint_derivatives(sk: &Sketch, ci: usize, pi: usize, order: usize, leaving: bool, what: &str, cmd: &str) -> Result> { + let id = sk.curves.get(ci).map(|c| c.id.as_str()).unwrap_or("?"); + let (start, end) = sk.curves.get(ci).and_then(|c| c.kind.ends()).ok_or_else(|| bad(cmd, format!("`{id}` has no end points")))?; + let at_end = end == pi; + if !at_end && start != pi { + return Err(bad(cmd, format!("`{what}` is not an end point of `{id}`"))); + } + let pieces = sk.exact_pieces(ci); + let seg = if at_end { pieces.last() } else { pieces.first() }.ok_or_else(|| bad(cmd, format!("`{id}` has no exact geometry")))?; + let mut d = seg + .derivatives(if at_end { 1.0 } else { 0.0 }, order) + .ok_or_else(|| bad(cmd, format!("`{id}` has no finite derivatives up to order {order} at `{what}`")))?; + // Reading the piece against its own direction, or reading the continuation into the curve + // rather than out of it, each reverse the parameterisation once and so flip the odd + // derivatives: the blend's start wants the continuation away from the reference's end (no + // flip) or backwards out of its start (flip); its end wants the mirror image of that, so a + // joint needs a flip exactly when its two readings disagree. + if at_end != leaving { + flip_odd(&mut d); + } + if !d.first().is_some_and(|t| t.len() > 0.0) { + return Err(bad(cmd, format!("`{id}` has no tangent at `{what}`"))); + } + Ok(d) +} + fn blend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.blend_curve"; + let order = blend_order(p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?; let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?; @@ -412,10 +475,34 @@ fn blend(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the ends must differ")); } let first_new = sk.points.len(); - let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; - let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; - let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + let (sp, cons) = if order <= 1 { + let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; + let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + (sp, cons) + } else { + // One Bézier of degree 2·order + 1 whose controls are the Hermite interpolation of the + // two references' derivatives, so its 1st…order-th derivatives are theirs at both + // joints. Tangency comes from the construction, so it needs no tangent constraints. + let da = joint_derivatives(sk, ca, pa, order, true, "a", cmd)?; + let db = joint_derivatives(sk, cb, pb, order, false, "b", cmd)?; + let mut pts: Vec = Vec::with_capacity(2 * order + 2); + let cps = solidcraft_geom::hermite_bezier(qa, qb, &da, &db) + .ok_or_else(|| bad(cmd, "the blend curve could not be built from the derivatives of `a` and `b`"))?; + for (i, q) in cps.iter().enumerate() { + pts.push(if i == 0 { + pa + } else if i + 1 == cps.len() { + pb + } else { + sk.add_point(*q, None)? + }); + } + let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?; + let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false }, None)?; + (sp, Vec::new()) + }; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) })?; @@ -860,3 +947,391 @@ pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) { } } } + +#[cfg(test)] +mod blend_tests { + use serde_json::{Value, json}; + use solidcraft_geom::{Seg2, Vec2}; + use solidcraft_sketch::CurveKind; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn ids(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + } + + fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s + } + + fn sketch(s: &Session) -> solidcraft_sketch::Sketch { + let id = s.active_sketch.unwrap(); + s.model.state().sketch(id).unwrap().sketch.clone() + } + + fn factorial(n: usize) -> f64 { + (1..=n).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` of exact piece `seg` at its end (`at_end`), from the piece's own + /// geometry: what a blend must match, computed here independently of `Seg2::derivatives`. + fn piece_derivatives(seg: &Seg2, at_end: bool, order: usize) -> Vec { + let t = if at_end { 1.0 } else { 0.0 }; + let p = seg.point_at(t); + let mut d = match *seg { + Seg2::Line { a, b } => vec![b - a, Vec2::ZERO, Vec2::ZERO, Vec2::ZERO], + Seg2::Arc { center, sweep, .. } => { + let (v, s2) = (seg.derivative(t), sweep * sweep); + vec![v, (p - center) * -s2, v * -s2, (p - center) * (s2 * s2)] + } + Seg2::Cubic { p0, p1, p2, p3 } => { + let third = (p3 - p2 * 3.0 + p1 * 3.0 - p0) * 6.0; + if at_end { + vec![(p3 - p2) * 3.0, (p3 - p2 * 2.0 + p1) * 6.0, third, Vec2::ZERO] + } else { + vec![(p1 - p0) * 3.0, (p0 - p1 * 2.0 + p2) * 6.0, third, Vec2::ZERO] + } + } + Seg2::Conic { a, apex, b, w } => { + // The rational quadratic's numerator N and weight D as quadratics in the power + // basis; dividing N's series by D's (constant term 1 at both ends) has to give the + // Taylor coefficients of the curve. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let h = 1e-3; + let series = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = series(&|x| n0 + n1 * x + n2 * (x * x)); + let den = [1.0 + e1 * t + e2 * (t * t), e1 + e2 * (2.0 * t), e2]; + let mut x = vec![n[0] / den[0]]; + for j in 1..=order { + let mut v = n.get(j).copied().unwrap_or(Vec2::ZERO); + for i in 1..=j { + v -= x[j - i] * den.get(i).copied().unwrap_or(0.0); + } + x.push(v / den[0]); + } + x.remove(0); + x.into_iter().enumerate().map(|(i, v)| v * factorial(i + 1)).collect() + } + Seg2::Bezier { .. } => panic!("a test piece is not a high-degree Bézier"), + }; + d.truncate(order); + d + } + + /// The derivatives 1…`order` the blend's own control polygon carries at an end: the j-th + /// difference of the controls, scaled by `n!/(n − j)!` (the Bézier endpoint identity). + fn blend_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + /// The blend curve's control points, its degree and the one exact piece they make. + fn blend_of(s: &Session, id: &str) -> (Vec, usize, Seg2) { + let sk = sketch(s); + let ci = sk.curve_index(id).unwrap(); + let ctrl: Vec = sk.curves[ci].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect(); + let degree = match sk.curves[ci].kind { + CurveKind::Spline { degree, .. } => usize::from(degree), + ref other => panic!("{other:?}"), + }; + let pieces = sk.exact_pieces(ci); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + (ctrl, degree, pieces[0]) + } + + /// The reference pair the tests build: a line `(0, 0)`–`(6, 0)` ending at `a` and a three-point + /// control spline starting at `b`, so the blend runs out of the first and into the second with + /// no reversal at either joint. + fn two_references(s: &mut Session) -> (String, String, [Vec2; 4]) { + let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [6, 0]]}))["curves"])[0].clone(); + let bctrl = [Vec2::new(20.0, 10.0), Vec2::new(24.0, 14.0), Vec2::new(30.0, 9.0), Vec2::new(34.0, 6.0)]; + let b = ids(&run(s, "sketch.spline.control_point", json!({"points": [[20, 10], [24, 14], [30, 9], [34, 6]]}))["curves"])[0].clone(); + (a, b, bctrl) + } + + #[test] + fn blend_curve_matches_both_references_through_the_requested_order() { + for order in 1..=4usize { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1, "g{order} is one Bézier of degree 2k+1"); + assert_eq!(ctrl.len(), 2 * order + 2, "g{order} control count"); + assert!(r["sketch"]["solved"].as_bool().unwrap_or(false), "{r}"); + assert!(piece.start().dist(Vec2::new(6.0, 0.0)) < 1e-12 && piece.end().dist(bctrl[0]) < 1e-12, "the blend joins `a` and `b`"); + + // What each reference supplies at the joint: the line's own derivative at its end (and + // nothing higher) and the cubic's at its start. + let line = piece_derivatives(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(6.0, 0.0) }, true, 4); + let cubic = piece_derivatives(&Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }, false, 4); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + // g1 is the tangent construction of the tool: the first derivative is parallel to the + // reference's (that is what G1 is), with the tool's own chord-scaled magnitude. g2 and + // up match the reference's derivative vectors themselves. + let tan_line = line[0].normalized().unwrap(); + let tan_cubic = cubic[0].normalized().unwrap(); + assert!(got0[0].cross(tan_line).abs() < 1e-9 && got0[0].dot(tan_line) > 0.0, "g{order} leaves `a` along {tan_line:?}, not {:?}", got0[0]); + assert!( + got1[0].cross(tan_cubic).abs() < 1e-9 && got1[0].dot(tan_cubic) > 0.0, + "g{order} enters `b` along {tan_cubic:?}, not {:?}", + got1[0] + ); + for j in 1..order { + assert!(got0[j].dist(line[j]) < 1e-9, "g{order} at a, order {}: {:?} vs {:?}", j + 1, got0[j], line[j]); + assert!(got1[j].dist(cubic[j]) < 1e-9, "g{order} at b, order {}: {:?} vs {:?}", j + 1, got1[j], cubic[j]); + } + if order >= 2 { + for (got, want) in [(got0[0], line[0]), (got1[0], cubic[0])] { + assert!(got.dist(want) < 1e-9, "g{order} matches the reference's own derivative: {got:?} vs {want:?}"); + } + } + // The next order up is not met: the line has no derivative there at all, while a blend of + // degree 2k+1 has. + if order < 4 { + let next = got0[order]; + assert!(next.dist(line[order]) > 1e-6, "g{order} matched one order too many at a: {next:?}"); + } + // Curvature itself (orientation-free, from finite differences of the samples) agrees at + // both joints once the order asks for it. + if order >= 2 { + let curve = |x: f64| piece.point_at(x.clamp(0.0, 1.0)); + let line_f = |x: f64| Vec2::new(6.0 * x, 0.0); + let cubic_f = |x: f64| Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }.point_at(x); + let k0 = solidcraft_sketch::end_curvature(&curve, false); + let ka = solidcraft_sketch::end_curvature(&line_f, true); + let k1 = solidcraft_sketch::end_curvature(&curve, true); + let kb = solidcraft_sketch::end_curvature(&cubic_f, false); + assert!(k0.dist(ka) < 1e-3, "g{order} curvature at a: {k0:?} vs {ka:?}"); + assert!(k1.dist(kb) < 1e-3, "g{order} curvature at b: {k1:?} vs {kb:?}"); + } + } + } + + #[test] + fn blend_curve_g1_is_the_tangent_construction_with_its_constraints() { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")})); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 3, "the default is the cubic tangent spline"); + let (qa, qb) = (Vec2::new(6.0, 0.0), bctrl[0]); + let l = qa.dist(qb); + let tb = (qb - bctrl[1]).normalized().unwrap(); + let want = [qa, qa + Vec2::X * (l / 3.0), qb + tb * (l / 3.0), qb]; + for (i, q) in want.iter().enumerate() { + assert!(ctrl[i].dist(*q) < 1e-9, "g1 control {i}: {:?} vs {q:?}", ctrl[i]); + } + assert_eq!(ids(&r["constraints"]).len(), 2, "the two tangents are kept: {r}"); + // g2…g4 meet the derivatives by construction and need no constraint of their own. + for order in 2..=4usize { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + assert!(ids(&r["constraints"]).is_empty(), "g{order}: {r}"); + } + } + + #[test] + fn blend_curve_between_two_lines_is_curvature_free_to_the_requested_order() { + // Two lines meeting at a right angle: from G2 on, the blend has the lines' own zero + // curvature at both joints (a corner with no curvature jump). + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + assert!(got0[0].dist(Vec2::new(2.0, 0.0)) < 1e-12, "g{order} leaves `a` along +x: {:?}", got0[0]); + assert!(got1[0].dist(Vec2::new(0.0, 2.0)) < 1e-12, "g{order} enters `b` along +y: {:?}", got1[0]); + for j in 1..order { + assert!(got0[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + assert!(got1[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + } + if order < 4 { + assert!(got0[order].len() > 1e-6, "g{order}: the blend curves, the line does not"); + } + assert!(piece.start().dist(Vec2::new(2.0, 0.0)) < 1e-12 && piece.end().dist(Vec2::new(20.0, 10.0)) < 1e-12); + } + } + + #[test] + fn blend_curve_matches_a_conic_reference() { + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.conic", json!({"start": [20, 10], "apex": [26, 16], "end": [30, 10], "rho": 0.7}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + // The conic's own derivatives at its start: `w` comes from the sketch's `rho`, and the + // series division in `piece_derivatives` is the expectation. + let w = 0.7 / (1.0 - 0.7); + let conic = Seg2::Conic { a: Vec2::new(20.0, 10.0), apex: Vec2::new(26.0, 16.0), b: Vec2::new(30.0, 10.0), w }; + let want = piece_derivatives(&conic, false, order); + let got = blend_derivatives(&ctrl, true, order); + for j in 0..order { + assert!(got[j].dist(want[j]) < 1e-8 * want[j].len().max(1.0), "g{order} order {}: {:?} vs {:?}", j + 1, got[j], want[j]); + } + } + } + + #[test] + fn blend_curve_refuses_hostile_orders_and_degenerate_references() { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let ends = json!({"a": format!("{a}.end"), "b": format!("{b}.start")}); + for (order, want) in [("g0", true), ("g9", true), ("G9", true), ("curvature", true), ("", false), ("g2", false), ("G4", false)] { + let mut p = ends.clone(); + p["continuity"] = json!(order); + let r = s.execute("sketch.blend_curve", &p); + assert_eq!(r.is_err(), want, "continuity {order:?}: {r:?}"); + assert!(r.is_ok() || r.unwrap_err().to_string().contains("continuity"), "continuity {order:?} needs a clear error"); + } + let mut numbered = ends.clone(); + numbered["continuity"] = json!(3); + assert!(s.execute("sketch.blend_curve", &numbered).is_err(), "a number is not a continuity"); + // A point that is not an end of a curve, and the same end twice. + let p = ids(&run(&mut s, "sketch.point", json!({"point": [50, 50]}))["created"]["points"])[0].clone(); + let e = s.execute("sketch.blend_curve", &json!({"a": p, "b": format!("{b}.start")})).unwrap_err().to_string(); + assert!(e.contains("no curve ends at `a`"), "{e}"); + let e = s.execute("sketch.blend_curve", &json!({"a": format!("{a}.end"), "b": format!("{a}.end")})).unwrap_err().to_string(); + assert!(e.contains("ends must differ"), "{e}"); + // The helper behind the command refuses a corrupt (non-finite) reference and a reference + // with no tangent at all, never guessing at them. + let mut broken = solidcraft_sketch::Sketch::new(); + let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap(); + let p1 = broken.add_point(Vec2::ZERO, None).unwrap(); + let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false }, None).unwrap(); + broken.points[p0].pos = Vec2::new(f64::NAN, 4.0); + assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry"); + let mut flat = solidcraft_sketch::Sketch::new(); + let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false }, None).unwrap(); + let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string(); + assert!(e.contains("no tangent"), "{e}"); + assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4"); + } + + #[test] + fn blend_curve_with_g4_closes_a_profile_that_measures_exactly() { + // A quarter-turn blend from (2, 0) into a line at (20, 10), closed by the chord between its + // ends: the region's area comes from the degree-9 curve, not from its chord. + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": "g4", + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 9, "G4 is one degree-9 Bézier"); + assert_eq!(ctrl.len(), 10); + // The command still reports what it added, with stable ids: ten controls of which the two + // joints are the references' own points, so eight new points, and no new constraint. + assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}"); + assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}"); + assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}"); + run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"})); + // Drop the two references: what is left is the blend and its chord, one closed region. + run(&mut s, "sketch.delete", json!({"entities": [a, b]})); + let id = s.active_sketch.unwrap(); + let state = s.model.state(); + let profiles = &state.sketch(id).unwrap().profiles; + assert_eq!(profiles.len(), 1, "the blend and the chord close one region"); + let area = profiles[0].area; + // A fine sampling of the exact piece plus the straight chord reproduces the area; the chord + // alone (what a polyline approximation would give) is exactly zero. + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + sampled += 0.5 * piece.point_at(t0).cross(piece.point_at(t1)); + } + // The chord back from (20, 10) to (2, 0) contributes its own shoelace term; together they + // are the region. + sampled += 0.5 * Vec2::new(20.0, 10.0).cross(Vec2::new(2.0, 0.0)); + assert!((area - sampled.abs()).abs() < 1e-4, "{area} vs {sampled}"); + assert!(area > 1e-2, "the blend curves away from the chord: {area}"); + // And the degree-9 profile still extrudes to its area times the height (the kernel's + // volume of a free-form body is a numerical integration, so this is the same 2e-4 the + // neighbouring free-form extrusion test allows). + run(&mut s, "solid.extrude", json!({"distance": 5})); + let m = run(&mut s, "inspect.measure", json!({})); + let volume = m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((volume - area * 5.0).abs() / (area * 5.0) < 2e-4, "volume {volume} vs {}", area * 5.0); + assert!(m["bodies"][0]["faces"].as_u64().unwrap_or(0) >= 3, "a solid with the blend as a side: {m}"); + } +} diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs index 1f3b1543..bb8afe8c 100644 --- a/crates/engine/src/cmd/sketch_support.rs +++ b/crates/engine/src/cmd/sketch_support.rs @@ -5,6 +5,11 @@ use solidcraft_sketch::{Shape, intersections}; use crate::{Result, params::bad}; +/// Degrees the exact extend intersection handles: a round support squares the piece's degree in +/// homogeneous form, and both the root finder and its `evaluate` buffer hold at most 15 +/// coefficients, so 2·7 + 1 fits. +const MAX_EXACT_EXTEND_DEGREE: u8 = 7; + fn choose(n: usize, k: usize) -> f64 { (0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64) } @@ -113,7 +118,7 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result, Vec) = match *seg { Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=MAX_EXACT_EXTEND_DEGREE).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), _ => return Err(bad("sketch.extend", "invalid exact curve piece")), }; if points.iter().any(|p| !p.is_finite()) { @@ -195,8 +200,8 @@ mod tests { elevated.push(y[n - 1]); y = elevated; } - let mut points = [Vec2::ZERO; 8]; - for (i, p) in points.iter_mut().enumerate() { + let mut points = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; + for (i, p) in points.iter_mut().take(8).enumerate() { *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); } let target = Seg2::Bezier { n: 7, p: points }; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 4e76e0d4..bde3ffa2 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.loft", "Surface Loft", surface_loft) .at("SURFACE", "CREATE") .icon("surface_loft") - .params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), + .params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), CommandSpec::new("surface.offset", "Offset Surface", offset) .at("SURFACE", "CREATE") .icon("surface_offset") @@ -369,15 +369,57 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result { } let mut sections = Vec::with_capacity(given.len()); for sec in given { + // A section is a chain of sketch curves, or an edge chain of a body whose face the end of + // the loft repeats. + if let Some(body) = str_(sec, "body") { + let edges: Vec = + sec.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default(); + if edges.is_empty() || edges.len() > 10_000 { + return Err(bad(cmd, "`sections.edges` must list 1 to 10000 points on the edges of a chain")); + } + if sec.get("sketch").is_some() { + return Err(bad(cmd, "a section is either a sketch chain or a body edge chain")); + } + sections.push(solidcraft_doc::SheetSection { + sketch: SketchId(0), + profiles: None, + curves: Vec::new(), + body: Some(BodyName::from(body.to_string())), + edges: edges.into_iter().map(Position).collect(), + }); + continue; + } let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?; let curves = string_list(sec, "curves"); if curves.len() > 1000 { return Err(bad(cmd, "at most 1000 curves a section")); } let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None }; - sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves }); + sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves, body: None, edges: Vec::new() }); } - add_feature(s, p, FeatureKind::SurfaceLoft { sections }) + let continuity = match p.get("continuity") { + None => [Continuity::G0, Continuity::G0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?; + let word = |x: Option<&Value>| -> Result { + x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3")) + }; + [word(list.first())?, word(list.get(1))?] + } + }; + let crown = match p.get("crown") { + None | Some(Value::Null) => [0.0, 0.0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`crown` must be two numbers, one per end"))?; + let one = |x: Option<&Value>| -> Result { + x.and_then(Value::as_f64) + .filter(|x| x.is_finite() && (-0.9..=3.0).contains(x)) + .ok_or_else(|| bad(cmd, "each `crown` entry must be between -0.9 (flattest) and 3 (fullest)")) + }; + [one(list.first())?, one(list.get(1))?] + } + }; + add_feature(s, p, FeatureKind::SurfaceLoft { sections, continuity, crown }) } fn offset(s: &mut Session, p: &Value) -> Result { @@ -1212,7 +1254,7 @@ mod tests { /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { - let planned = ["surface.fillet", "inspect.continuity"]; + let planned = ["surface.fillet"]; let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); let before = s.doc.features.len(); @@ -1588,4 +1630,68 @@ mod tests { assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err()); assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err()); } + + /// A loft through three sections whose first and last are edge chains of two boxes, with + /// `continuity: ["g1","g1"]`: it leaves each box's top face tangentially (the run direction + /// lies in that face) and passes through the sketch between them. + #[test] + fn surface_loft_leaves_a_box_face_tangentially() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Low"})); + run(&mut s, "solid.box", json!({"corner": [40, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "High"})); + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 30, "name": "Mid"})); + run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "Bump"})); + run(&mut s, "sketch.line", json!({"points": [[0, 14], [20, 14]], "ids": ["b"]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "surface.loft", + json!({ + "sections": [ + {"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, + {"sketch": "Bump", "curves": ["b"]}, + {"body": "High", "edges": [[40, 5, 10], [40, 15, 10]]} + ], + "continuity": ["g1", "g1"], + "crown": [0, 0], + "body_name": "Blend" + }), + ); + let body = s.world_state().body("Blend").unwrap().body.clone(); + // A fine grid in the run direction: the seams sit at u = 0, 1/2 and 1, and the first step + // off each one is the tangent there (a thousandth of the run, so it is the tangent). + let grid = solidcraft_kernel::sample_grid(&body, 1001, 9).unwrap(); + // The ends lie on the two boxes' top faces, and the sketch is met exactly between them. + for p in &grid[0] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 20.0).abs() < 1e-9, "the first seam is the box edge: {p:?}"); + } + for p in &grid[1000] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 40.0).abs() < 1e-9, "the last seam is the box edge: {p:?}"); + } + for p in &grid[500] { + assert!((p.z - 14.0).abs() < 1e-9 && (p.x - 30.0).abs() < 1e-9, "the blend meets the sketch: {p:?}"); + } + // The seam leaves along the box's top face: the run direction the blend is given is what + // the face holds, so the tangent there is horizontal and along the run. (The step the grid + // takes is a thousandth of the run, and a chord over that step carries an O(h²) bias of + // about 0.011 here, so 1e-2 on the rise off the face is what the grid can measure; the + // kernel's own tests pin the tangent to the frame's leave direction exactly.) + for j in [0usize, 4, 8] { + let d = (grid[1][j] - grid[0][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend leaves the low box's top face: {d:?}"); + let d = (grid[1000][j] - grid[999][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend arrives along the high box's top face: {d:?}"); + } + // Hostile parameters are refused, never panicking. + let bad = [ + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1"]}), + json!({"sections": [{"body": "Low", "edges": []}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1", "g1"]}), + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10]], "sketch": "Bump"}, {"sketch": "Bump", "curves": ["b"]}]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "crown": [5, 0]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g9", "g1"]}), + ]; + for p in bad { + assert!(s.execute("surface.loft", &p).is_err(), "hostile loft params: {p}"); + } + } } diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index dbcea9f0..b0f6e456 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -5,8 +5,19 @@ use serde_json::{Value, json}; use solidcraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, bool_, num, vec3}; -use crate::{Result, Session, SurfaceAnalysis}; +use crate::params::{bad, bool_, num, str_, vec3}; +use crate::{ContinuitySeam, Result, Session, SurfaceAnalysis, kernel}; + +/// Stations along each edge when the call does not say (`samples`). +const DEFAULT_SAMPLES: usize = 33; +/// Tolerances a seam is judged against when the call does not say: a micron of position gap, a +/// degree of normal angle, and a curvature jump of 1/100 mm. +const DEFAULT_GAP_MM: f64 = 1e-3; +const DEFAULT_ANGLE_DEG: f64 = 1.0; +const DEFAULT_CURVATURE_PER_MM: f64 = 0.01; +/// Most edges one call may pick, and most bodies one call may analyse at once. +const MAX_PICKS: usize = 512; +const MAX_BODIES: usize = 64; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra) @@ -34,11 +45,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("curvature_map") .noundo() .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), - planned!("inspect.continuity", "Continuity Analysis", "770") - .at("SURFACE", "INSPECT") - .icon("continuity") - .noundo() - .params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"), + CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params( + "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ + a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \ + gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \ + clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \ + {value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \ + the seams are shaded in the viewport", + ), ]; fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { @@ -94,12 +108,214 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result { set(s, p, SurfaceAnalysis::Curvature { radius }) } +/// `inspect.continuity`: measure how far the two faces meeting at each picked edge (or at every +/// edge of a body) depart from sharing a curve (G0), a tangent plane (G1) and a curvature (G2), +/// and keep the seams so the viewport can shade them. View state only: the design and its undo +/// history are untouched (`noundo`), as with the other analyses. +fn continuity(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.continuity"; + if bool_(p, "clear").unwrap_or(false) { + s.continuity.clear(); + s.revision += 1; + return Ok(json!({ "analysis": "continuity", "cleared": true })); + } + let samples = match p.get("samples") { + None => DEFAULT_SAMPLES, + Some(v) => match v.as_f64().filter(|n| n.is_finite() && n.fract() == 0.0 && (3.0..=257.0).contains(n)) { + Some(n) => n as usize, + None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")), + }, + }; + let tolerances = kernel::ContinuityTolerances { + gap_mm: tolerance(p, "gap_tolerance_mm", DEFAULT_GAP_MM)?, + angle_deg: tolerance(p, "angle_tolerance_deg", DEFAULT_ANGLE_DEG)?, + curvature_per_mm: tolerance(p, "curvature_tolerance_per_mm", DEFAULT_CURVATURE_PER_MM)?, + }; + let picks = edge_picks(p)?; + let st = s.world_state(); + let named = str_(p, "body"); + let candidates: Vec<&solidcraft_doc::ModelBody> = match named { + Some(name) => vec![st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?], + None => st.bodies.iter().filter(|b| s.body_visible(&b.name)).collect(), + }; + if candidates.is_empty() { + return Err(bad(cmd, "there are no visible bodies to analyse: name a `body`")); + } + if candidates.len() > MAX_BODIES { + return Err(bad(cmd, format!("more than {MAX_BODIES} bodies are shown: name the `body` to analyse"))); + } + let mut targets: Vec<(&solidcraft_doc::ModelBody, Vec)> = Vec::new(); + if picks.is_empty() { + targets.extend(candidates.into_iter().map(|b| (b, Vec::new()))); + } else { + // Picks mean edges of one body (a design may stack bodies at the same place): a list + // that lands on one body's edges only is analysed, and anything else is refused. + let mut hits = Vec::new(); + let mut refused = None; + for b in candidates { + match kernel::pick_edges(&b.body, &picks) { + Ok(edges) => hits.push((b, edges)), + Err(e) => refused = refused.or(Some(e)), + } + } + if hits.is_empty() { + return Err(match (named, refused) { + (Some(_), Some(e)) => e.into(), + _ => bad(cmd, "no visible body has an edge matching `edges`"), + }); + } + if hits.len() > 1 { + return Err(bad(cmd, "`edges` matches edges of more than one body: name the `body` to analyse")); + } + targets.extend(hits); + } + let mut seams: Vec = Vec::new(); + let mut bodies = Vec::new(); + let mut summary = Summary::default(); + for (body, edges) in &targets { + let report = kernel::seam_diagnostics(&body.body, edges, samples, tolerances)?; + let mut rows = Vec::new(); + for seam in &report.seams { + summary.count(seam.verdict); + seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict }); + rows.push(json!({ + "edge": seam.edge, + "length_mm": seam.length, + "samples": seam.samples, + "gap_mm": seam.gap_mm, + "normal_angle_deg": seam.normal_angle_deg, + "curvature_jump_per_mm": seam.curvature_jump_per_mm, + "verdict": seam.verdict, + })); + } + summary.skipped += report.skipped; + summary.unmeasured += report.unmeasured; + bodies.push(json!({ + "body": body.name, + "edges": rows, + "skipped": report.skipped, + "unmeasured": report.unmeasured, + })); + } + s.continuity = seams; + s.revision += 1; + Ok(json!({ + "analysis": "continuity", + "samples": samples, + "tolerances": { + "gap_mm": tolerances.gap_mm, + "angle_deg": tolerances.angle_deg, + "curvature_per_mm": tolerances.curvature_per_mm, + }, + "bodies": bodies, + "summary": { + "edges": summary.edges, + "smooth": summary.smooth, + "curvature_jump": summary.curvature_jump, + "sharp": summary.sharp, + "gap": summary.gap, + "skipped": summary.skipped, + "unmeasured": summary.unmeasured, + "worst": summary.worst(), + }, + })) +} + +/// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a +/// string, a bare `null`) is refused: `num` would fold it into "not given". +fn tolerance(p: &Value, key: &str, default: f64) -> Result { + match p.get(key) { + None => Ok(default), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..1e9).contains(&x) => Ok(x), + _ => Err(bad("inspect.continuity", format!("`{key}` must be a finite number, 0 or more"))), + }, + } +} + +/// The `edges` parameter: points on the edges to measure, or `{"index": i}` entries in the +/// display mesh's edge order. Absent (or an empty list) means every edge of the body. +fn edge_picks(p: &Value) -> Result> { + let Some(value) = p.get("edges") else { return Ok(Vec::new()) }; + let Some(list) = value.as_array() else { + return Err(bad("inspect.continuity", "`edges` must be a list of [x,y,z] points or {\"index\": i} entries")); + }; + if list.len() > MAX_PICKS { + return Err(bad("inspect.continuity", format!("pick at most {MAX_PICKS} edges"))); + } + let mut out = Vec::new(); + for item in list { + if let Some(index) = item.get("index").and_then(Value::as_u64) { + let index = usize::try_from(index).map_err(|_| bad("inspect.continuity", "`index` is too large"))?; + out.push(kernel::EdgePick::Index(index)); + } else if let Some(point) = vec3(item) { + out.push(kernel::EdgePick::Point(point)); + } else { + return Err(bad("inspect.continuity", "each `edges` entry is a point [x,y,z] or {\"index\": i}")); + } + } + Ok(out) +} + +/// Counts of a continuity reply, and the worst verdict over every edge measured. +#[derive(Clone, Copy, Debug, Default)] +struct Summary { + edges: usize, + smooth: usize, + curvature_jump: usize, + sharp: usize, + gap: usize, + skipped: usize, + unmeasured: usize, +} + +impl Summary { + fn count(&mut self, verdict: kernel::SeamVerdict) { + self.edges += 1; + match verdict { + kernel::SeamVerdict::Smooth => self.smooth += 1, + kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1, + kernel::SeamVerdict::Sharp => self.sharp += 1, + kernel::SeamVerdict::Gap => self.gap += 1, + } + } + + /// The worst verdict over the edges measured (`None` when there was nothing to measure). + /// [`kernel::SeamVerdict`] runs from the best continuity to the worst. + fn worst(&self) -> Option { + [ + (self.smooth, kernel::SeamVerdict::Smooth), + (self.curvature_jump, kernel::SeamVerdict::CurvatureJump), + (self.sharp, kernel::SeamVerdict::Sharp), + (self.gap, kernel::SeamVerdict::Gap), + ] + .into_iter() + .filter(|(count, _)| *count > 0) + .map(|(_, verdict)| verdict) + .max() + } +} + #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::{Session, SurfaceAnalysis}; + fn boxed(s: &mut Session, size: f64) { + s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap(); + } + + /// The edges of the first body of a continuity reply. + fn edges(v: &Value) -> &Vec { + v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}")) + } + + /// The seams that read as tangent (within 0.01° of the neighbouring face). + fn tangent(edges: &[Value]) -> Vec<&Value> { + edges.iter().filter(|e| e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN) < 0.01).collect() + } + #[test] fn surface_analyses_are_view_state() { let mut s = Session::default(); @@ -121,4 +337,126 @@ mod tests { assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err()); assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err()); } + + /// A box edge: the faces meet exactly (G0) and square to each other (G1), so the analysis + /// must read 0 gap and 90°, and must keep its seams as view state without an undo step. + #[test] + fn continuity_reports_a_box_edge_as_a_gap_free_right_angle() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + let undo = s.undo.len(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 12); + for e in edges(&v) { + let gap = e["gap_mm"]["value"].as_f64().unwrap(); + let angle = e["normal_angle_deg"]["value"].as_f64().unwrap(); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!(gap < 1e-6, "gap {gap} in {e}"); + assert!((angle - 90.0).abs() < 1e-9, "angle {angle} in {e}"); + assert!(jump < 1e-9, "curvature jump {jump} in {e}"); + assert_eq!(e["verdict"], json!("sharp"), "{e}"); + assert_eq!(e["samples"], json!(33), "{e}"); + assert!((e["length_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{e}"); + assert_eq!(e["normal_angle_deg"]["at"].as_array().map(Vec::len), Some(3), "{e}"); + let f = e["normal_angle_deg"]["edge_fraction"].as_f64().unwrap(); + assert!((0.0..=1.0).contains(&f), "{e}"); + } + assert_eq!(v["analysis"], json!("continuity")); + assert_eq!(v["samples"], json!(33)); + assert_eq!(v["tolerances"], json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01})); + assert_eq!(v["summary"]["edges"], json!(12)); + assert_eq!(v["summary"]["sharp"], json!(12)); + assert_eq!(v["summary"]["worst"], json!("sharp")); + assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport"); + assert_eq!(s.undo.len(), undo, "no undo step"); + assert_eq!(s.analysis, None, "the other analyses are left alone"); + } + + /// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its + /// two seams read ~0° with the fillet's 1 / radius curvature jump against the planes. + #[test] + fn continuity_finds_the_fillet_seams_tangent_and_their_curvature_jump() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 15, "the rounded edge leaves three new edges behind: {v}"); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 2, "the fillet meets its two neighbours tangentially: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.25).abs() < 1e-6, "1 / radius 4: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(2)); + assert_eq!(v["summary"]["sharp"], json!(13)); + assert_eq!(v["summary"]["smooth"], json!(0)); + } + + /// A slot's straight wall runs tangentially into its arc wall: the join is G1 but its + /// curvature jumps by the arc's 1 / radius, which is exactly what a Class-A check looks for. + #[test] + fn continuity_reports_one_over_the_radius_where_a_line_meets_an_arc() { + let mut s = Session::default(); + s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); + s.execute("sketch.slot.center_point", &json!({"center": [0, 0], "end": [20, 0], "width": 10})).unwrap(); + s.execute("solid.extrude", &json!({"distance": 5})).unwrap(); + let v = s.execute("inspect.continuity", &json!({"angle_tolerance_deg": 0.01})).unwrap(); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 4, "a slot has four line-to-arc joins: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.2).abs() < 1e-6, "1 / radius 5: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(4)); + } + + /// `clear` takes the shading away, and every continuity call stays view state. + #[test] + fn continuity_is_view_state_and_clears() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + let undo = s.undo.len(); + s.execute("inspect.continuity", &json!({"samples": 5})).unwrap(); + assert_eq!(s.continuity.len(), 12); + assert_eq!(s.undo.len(), undo, "no undo step"); + let cleared = s.execute("inspect.continuity", &json!({"clear": true})).unwrap(); + assert_eq!(cleared["cleared"], json!(true)); + assert!(s.continuity.is_empty(), "clear leaves nothing shaded"); + assert_eq!(s.undo.len(), undo); + } + + /// Hostile parameters are refused with an error, never a panic. + #[test] + fn hostile_continuity_params_are_refused() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + for params in [ + json!({"samples": 0}), + json!({"samples": 2}), + json!({"samples": 258}), + json!({"samples": 3.5}), + json!({"samples": "33"}), + json!({"samples": null}), + json!({"gap_tolerance_mm": -1}), + json!({"gap_tolerance_mm": 1e12}), + json!({"angle_tolerance_deg": f64::NAN}), + json!({"curvature_tolerance_per_mm": "tight"}), + json!({"edges": 3}), + json!({"edges": [[1, 2]]}), + json!({"edges": [[]]}), + json!({"edges": [{"index": 99999}]}), + json!({"edges": [[500, 500, 500]]}), + json!({"body": "Nope"}), + ] { + assert!(s.execute("inspect.continuity", ¶ms).is_err(), "accepted {params}"); + } + let many: Vec = (0..513).map(|i| json!([i as f64, 0.0, 0.0])).collect(); + assert!(s.execute("inspect.continuity", &json!({"edges": many})).is_err(), "513 picks"); + assert!(s.continuity.is_empty(), "a refused call shades nothing"); + assert_eq!(s.undo.len(), 1, "only the box is an undo step"); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d714352c..b5c2da8d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -239,6 +239,9 @@ pub struct Session { pub section: Option<(Vec3, Vec3)>, /// Surface analysis shading the model (zebra, draft, curvature map). pub analysis: Option, + /// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not + /// part of the design and not an undo step. + pub continuity: Vec, /// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an /// undo step. A design brings its saved visibility when it opens (`Session::new`). pub visibility: Visibility, @@ -301,6 +304,16 @@ pub enum SurfaceAnalysis { Access { dir: Vec3 }, } +/// One seam measured by `inspect.continuity`, where the viewport shades it: the stations it was +/// sampled at, in world coordinates, and how it reads. View state only: the design is untouched. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct ContinuitySeam { + /// Sample points along the seam, in world coordinates. + pub points: Vec, + /// The seam's worst verdict along its length. + pub verdict: kernel::SeamVerdict, +} + impl SurfaceAnalysis { /// How a picture shades the faces for this analysis. pub fn mode(self) -> render::AnalysisMode { @@ -356,6 +369,7 @@ impl Session { sketch_clipboard: None, section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, saved_visibility: visibility.clone(), @@ -823,6 +837,7 @@ impl Session { sketch_clipboard: self.sketch_clipboard.clone(), section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, visibility: self.visibility.clone(), diff --git a/crates/geom/src/blend.rs b/crates/geom/src/blend.rs new file mode 100644 index 00000000..6466ff98 --- /dev/null +++ b/crates/geom/src/blend.rs @@ -0,0 +1,53 @@ +//! Blend curves: one Bézier that joins two curves with a requested continuity order, by Hermite +//! interpolation of each reference curve's position and derivatives at the joints. This is the +//! geometry `sketch.blend_curve` builds; it knows nothing about sketches or the document. + +use crate::{MAX_DERIVATIVE_ORDER, Vec2, profile::binom}; + +/// Control points of the Bézier of degree `2k + 1`, `k = d0.len()`, that starts at `p0` with +/// derivatives `d0` and ends at `p1` with derivatives `d1`, all with respect to its own parameter +/// on [0, 1]. The derivative vectors are the reference curves' at the joints, measured in their +/// own parameters and oriented the way the blend runs through the joint, so the blend agrees with +/// each reference through its k-th derivative (G1, G2, G3, G4 for k = 1…4). +/// +/// The two ends fix exactly half of the controls each: a Bézier's j-th forward difference is +/// `P⁽ʲ⁾(0)·(n − j)!/n!`, so the first `k + 1` controls follow from the start data and the last +/// `k + 1` from the end data, which meet exactly at the middle control. `None` when the ends +/// disagree on the order, the order is above [`MAX_DERIVATIVE_ORDER`], or a value is not finite. +pub fn hermite_bezier(p0: Vec2, p1: Vec2, d0: &[Vec2], d1: &[Vec2]) -> Option> { + let k = d0.len(); + if k != d1.len() || k > MAX_DERIVATIVE_ORDER || !p0.is_finite() || !p1.is_finite() || d0.iter().chain(d1).any(|d| !d.is_finite()) { + return None; + } + let n = 2 * k + 1; + let mut c = vec![Vec2::ZERO; n + 1]; + c[0] = p0; + for j in 1..=k { + // `Δʲp₀ = P⁽ʲ⁾(0)·(n − j)!/n!`, and solving it for `pⱼ` puts `(−1)^(j − i + 1)` on every + // control already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d0[j - 1] * scale; + for (i, p) in c.iter().take(j).enumerate() { + value += *p * (odd_sign(j - i) * binom(j, i)); + } + c[j] = value; + } + c[n] = p1; + for j in 1..=k { + // The same at the far end, reading the controls backwards: `Σᵢ(−1)ⁱ·C(j,i)·pₙ₋ᵢ` is + // `P⁽ʲ⁾(1)·(n − j)!/n!`, and solving that for `pₙ₋ⱼ` puts `(−1)^(j + i + 1)` on every control + // already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d1[j - 1] * (scale * odd_sign(j + 1)); + for (i, p) in c[n - j + 1..].iter().rev().enumerate() { + value += *p * (odd_sign(j + i) * binom(j, i)); + } + c[n - j] = value; + } + c.iter().all(|p| p.is_finite()).then_some(c) +} + +/// `(−1)^(m + 1)`: the sign an m-th difference expansion carries in a Bézier's endpoint identity. +fn odd_sign(m: usize) -> f64 { + if m % 2 == 1 { 1.0 } else { -1.0 } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 850f471f..347d150d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod arcfit; +pub mod blend; pub mod brep; pub mod bspline; pub mod compare; @@ -23,9 +24,10 @@ pub mod tspline; mod vec; pub use arcfit::MAX_FIT_PIECES; +pub use blend::hermite_bezier; pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair}; pub use plane::Plane; -pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier, regions_apart}; +pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart}; pub use vec::{Vec2, Vec3}; /// Geometric tolerance for coincidence tests (mm). diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 4ff22666..e1117898 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -38,26 +38,35 @@ pub enum Seg2 { b: Vec2, w: f64, }, - /// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree - /// splines. + /// Bézier of degree `n` (4…[`MAX_BEZIER_DEGREE`]) over `p[0..=n]` (the rest unused): pieces of + /// higher-degree splines. Bezier { n: u8, - p: [Vec2; 8], + p: [Vec2; MAX_BEZIER_CTRL], }, } -/// Highest degree of [`Seg2::Bezier`]. -pub const MAX_BEZIER_DEGREE: usize = 7; +/// Highest degree of [`Seg2::Bezier`]: a degree-9 piece is the single Bézier a G4 blend curve +/// (`sketch.blend_curve` with `continuity: "g4"`) is made of. +pub const MAX_BEZIER_DEGREE: usize = 9; -/// A Bézier segment over `pts` (2…8 points): a line, a cubic or a higher-degree piece. A -/// quadratic becomes a conic of weight 1. +/// Control points a [`Seg2::Bezier`] carries: its highest degree plus one (the unused tail is +/// zero). +pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1; + +/// Highest derivative order [`Seg2::derivatives`] hands out, and therefore the highest +/// continuity order a blend curve can be built for (4 = G4). +pub const MAX_DERIVATIVE_ORDER: usize = 4; + +/// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree +/// piece. A quadratic becomes a conic of weight 1. pub fn bezier(pts: &[Vec2]) -> Option { Some(match *pts { [a, b] => Seg2::Line { a, b }, [a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 }, [p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 }, - _ if pts.len() <= MAX_BEZIER_DEGREE + 1 => { - let mut p = [Vec2::ZERO; 8]; + _ if (2..=MAX_BEZIER_CTRL).contains(&pts.len()) => { + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; for (d, s) in p.iter_mut().zip(pts) { *d = *s; } @@ -86,6 +95,38 @@ fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec, Vec) { (left.last().copied().unwrap_or(Vec2::ZERO), left, right) } +/// Binomial coefficient `C(n, k)` for the small orders the derivative rules need (0 when +/// `k > n`). +pub(crate) fn binom(n: usize, k: usize) -> f64 { + if k > n { + return 0.0; + } + (0..k.min(n - k)).fold(1.0, |v, i| v * (n - i) as f64 / (i + 1) as f64) +} + +/// Derivatives 1…`order` of the Bézier over `c` (degree `c.len() - 1`) at `t`: the j-th forward +/// difference of the control polygon, scaled by `n!/(n − j)!` and evaluated by de Casteljau. +/// Exact in exact arithmetic, zero above the degree, `None` for non-finite input. +fn bezier_derivatives(c: &[Vec2], t: f64, order: usize) -> Option> { + if !t.is_finite() || c.is_empty() || c.iter().any(|p| !p.is_finite()) { + return None; + } + let n = c.len() - 1; + let mut diffs = c.to_vec(); + let mut scale = 1.0; + let mut out = Vec::with_capacity(order); + for j in 1..=order { + if diffs.len() < 2 { + out.push(Vec2::ZERO); + continue; + } + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(casteljau(&diffs, t).0 * scale); + } + Some(out) +} + /// Gauss–Legendre nodes and weights on [0, 1] (5 points). const GL5: [(f64, f64); 5] = [ (0.046_910_077_030_668, 0.118_463_442_528_095), @@ -96,7 +137,7 @@ const GL5: [(f64, f64); 5] = [ ]; impl Seg2 { - fn bez(n: u8, p: &[Vec2; 8]) -> &[Vec2] { + fn bez(n: u8, p: &[Vec2; MAX_BEZIER_CTRL]) -> &[Vec2] { &p[..(n as usize).clamp(1, MAX_BEZIER_DEGREE) + 1] } /// Derivative with respect to the parameter at `t` (Béziers and conics; lines and arcs too). @@ -121,6 +162,86 @@ impl Seg2 { } } } + /// Derivatives 1…`order` with respect to the segment's own parameter at `t`, exactly: a line + /// and an arc from their analytic form (an arc's are the circular-motion recurrence, zero + /// curvature terms included), a cubic and a higher-degree piece from the forward differences + /// of their controls, a conic through the quotient rule of its rational form. A blend curve + /// reads a reference curve's derivatives here to build a Hermite interpolation, so this is + /// exact at both ends (`t` = 0 and 1) and `None` — never a wrong number — when `order` is + /// above [`MAX_DERIVATIVE_ORDER`], `t` is not finite or a value is not finite, as with a + /// degenerate rational piece. + pub fn derivatives(&self, t: f64, order: usize) -> Option> { + if !t.is_finite() || order > MAX_DERIVATIVE_ORDER { + return None; + } + let mut d = match *self { + Seg2::Line { a, b } => { + if !a.is_finite() || !b.is_finite() { + return None; + } + let mut d = vec![b - a]; + d.resize(MAX_DERIVATIVE_ORDER, Vec2::ZERO); + d + } + Seg2::Arc { center, radius, start, sweep } => { + if !center.is_finite() || !radius.is_finite() || !start.is_finite() || !sweep.is_finite() { + return None; + } + // Differentiating the radial direction twice returns its negative, so the + // directions of the successive derivatives are tangential, radial, tangential… + // with alternating signs, each order picking up one more factor of the sweep. + let mut v = Vec2::from_angle(start + sweep * t).perp(); + let mut d = Vec::with_capacity(MAX_DERIVATIVE_ORDER); + for j in 1..=MAX_DERIVATIVE_ORDER { + d.push(v * (radius * sweep.powi(j as i32))); + v = v.perp(); + } + d + } + Seg2::Cubic { p0, p1, p2, p3 } => bezier_derivatives(&[p0, p1, p2, p3], t, MAX_DERIVATIVE_ORDER)?, + Seg2::Bezier { n, ref p } => bezier_derivatives(Self::bez(n, p), t, MAX_DERIVATIVE_ORDER)?, + Seg2::Conic { a, apex, b, w } => { + if !a.is_finite() || !apex.is_finite() || !b.is_finite() || !w.is_finite() { + return None; + } + // The conic is `N(t) / d(t)` with `N` the quadratic through `a`, `w·apex`, `b` and + // `d` its weight polynomial; both are evaluated here in the power basis (at the + // ends `d` = 1, so a join is never singular). The quotient rule in its + // derivative-of-a-product form gives the higher derivatives: the numerator of + // `x⁽ʲ⁾` is `N⁽ʲ⁾` minus the terms that already use `d` and a lower `x`. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let quotient = |j: usize| match j { + 0 => n0 + n1 * t + n2 * (t * t), + 1 => n1 + n2 * (2.0 * t), + 2 => n2 * 2.0, + _ => Vec2::ZERO, + }; + let weight = |j: usize| match j { + 0 => 1.0 + e1 * t + e2 * (t * t), + 1 => e1 + e2 * (2.0 * t), + 2 => e2 * 2.0, + _ => 0.0, + }; + let den = weight(0); + if !(den.abs() > 1e-300) { + return None; + } + let mut x = vec![quotient(0) / den]; + for j in 1..=MAX_DERIVATIVE_ORDER { + let mut v = quotient(j); + for i in 1..=j { + v -= x[j - i] * (weight(i) * binom(j, i)); + } + x.push(v / den); + } + x.remove(0); + x + } + }; + d.truncate(order); + d.iter().all(|q| q.is_finite()).then_some(d) + } pub fn start(&self) -> Vec2 { match *self { Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a, diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e607f32..39010d87 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -560,3 +560,161 @@ mod close_pairs { assert!(budget < 10_000_000, "work was counted"); } } + +mod blend { + use super::*; + + /// `j!` for the small orders the Hermite and quotient identities use. + fn fact(j: usize) -> f64 { + (1..=j).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` a Bézier's control polygon carries at its end `at_end`: the j-th + /// difference of the controls, scaled by `n!/(n − j)!`. This is the textbook endpoint identity + /// of a Bézier, written here independently of the library's. + fn control_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + // The reversed controls describe `P(1 − s)`, so reversing again to read the original + // parameterisation negates the odd orders. + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + #[test] + fn hermite_bezier_meets_the_derivative_data_at_both_ends() { + let (p0, p1) = (Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.5)); + let d0 = [Vec2::new(2.0, 1.0), Vec2::new(-0.5, 3.0), Vec2::new(1.5, 1.0), Vec2::new(0.25, -2.0)]; + let d1 = [Vec2::new(-1.0, 0.5), Vec2::new(2.0, -1.5), Vec2::new(-0.75, 0.5), Vec2::new(3.0, 1.25)]; + for k in 0..=MAX_DERIVATIVE_ORDER { + let cps = hermite_bezier(p0, p1, &d0[..k], &d1[..k]).unwrap(); + assert_eq!(cps.len(), 2 * k + 2, "degree 2k+1 has 2k+2 controls"); + assert_eq!(cps[0], p0); + assert_eq!(*cps.last().unwrap(), p1); + for (got, want) in control_derivatives(&cps, false, k).iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=0: {got:?} vs {want:?}"); + } + for (got, want) in control_derivatives(&cps, true, k).iter().zip(&d1[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=1: {got:?} vs {want:?}"); + } + // The piece is exactly the single Bézier `bezier` hands back, and its ends are the two + // joints. + let seg = bezier(&cps).unwrap(); + assert!(seg.start().dist(p0) < 1e-12 && seg.end().dist(p1) < 1e-12); + for (got, want) in seg.derivatives(0.0, k).unwrap().iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-10, "k={k} at t=0: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn hermite_bezier_refuses_hostile_data() { + let (p0, p1) = (Vec2::ZERO, Vec2::X); + let d = [Vec2::Y, Vec2::X]; + assert!(hermite_bezier(p0, p1, &d, &d).is_some()); + assert!(hermite_bezier(p0, p1, &d, &d[..1]).is_none(), "the ends must agree on the order"); + assert!(hermite_bezier(p0, p1, &[Vec2::ZERO; 5], &[Vec2::ZERO; 5]).is_none(), "order above G4"); + assert!(hermite_bezier(Vec2::new(f64::NAN, 0.0), p1, &[], &[]).is_none()); + assert!(hermite_bezier(p0, p1, &[Vec2::new(f64::INFINITY, 0.0)], &[Vec2::Y]).is_none()); + assert!(hermite_bezier(p0, p1, &[], &[Vec2::Y]).is_none()); + } + + #[test] + fn seg_derivatives_are_exact_for_lines_and_polynomial_pieces() { + let line = Seg2::Line { a: Vec2::new(-1.0, 2.0), b: Vec2::new(3.0, 5.0) }; + let d = line.derivatives(0.0, MAX_DERIVATIVE_ORDER).unwrap(); + assert!(d[0].dist(Vec2::new(4.0, 3.0)) < 1e-12); + assert_eq!(d[1].len() + d[2].len() + d[3].len(), 0.0, "a line has no curvature"); + let cubic = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(3.0, 9.0), p2: Vec2::new(7.0, -4.0), p3: Vec2::new(11.0, 2.0) }; + let ctrl = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 9.0), Vec2::new(7.0, -4.0), Vec2::new(11.0, 2.0)]; + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in cubic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&ctrl, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + for t in [0.0, 0.35, 1.0] { + assert!(cubic.derivatives(t, 1).unwrap()[0].dist(cubic.derivative(t)) < 1e-9, "t={t}"); + } + // A degree-9 piece (the G4 blend's own shape) at both ends. + let pts: Vec = (0..10).map(|i| Vec2::new(i as f64 * 0.7, (i as f64 * 1.3).sin())).collect(); + let seg = bezier(&pts).unwrap(); + assert!(matches!(seg, Seg2::Bezier { n: 9, .. })); + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in seg.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&pts, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn seg_derivatives_follow_the_circular_recurrence() { + let arc = Seg2::Arc { center: Vec2::new(2.0, -1.0), radius: 5.0, start: 0.7, sweep: -1.9 }; + let Seg2::Arc { center, sweep, .. } = arc else { panic!("an arc") }; + for t in [0.0, 0.4, 1.0] { + let d = arc.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + let p = arc.point_at(t); + let sw2 = sweep * sweep; + // On a circle every derivative follows from the second: P″ = −sw²(P − c), and the rest + // differentiate that relation, so a line and an arc have zero curvature rates. + assert!(d[1].dist((p - center) * -sw2) < 1e-9, "t={t}"); + assert!(d[2].dist(d[0] * -sw2) < 1e-9, "t={t}"); + assert!(d[3].dist(d[1] * -sw2) < 1e-9, "t={t}"); + assert!(d[0].dist(arc.derivative(t)) < 1e-9, "t={t}"); + } + } + + #[test] + fn seg_derivatives_of_a_conic_satisfy_the_quotient_identity() { + let conic = Seg2::Conic { a: Vec2::new(-2.0, 0.0), apex: Vec2::new(0.0, 3.0), b: Vec2::new(4.0, 0.5), w: 0.7 }; + let Seg2::Conic { a, apex, b, w } = conic else { panic!("a conic") }; + // The rational quadratic's numerator and weight polynomials, straight from their Bernstein + // form, and their power coefficients at `t` by differences (exact for a quadratic). + let n_at = |x: f64| a * ((1.0 - x) * (1.0 - x)) + apex * (2.0 * w * x * (1.0 - x)) + b * (x * x); + let d_at = |x: f64| (1.0 - x) * (1.0 - x) + 2.0 * w * x * (1.0 - x) + x * x; + for t in [0.0, 0.25, 1.0] { + let h = 1e-3; + // Taylor coefficients at `t` (differences are exact for a quadratic): f, f′/1!, f″/2!. + let coef_v = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let coef_s = |f: &dyn Fn(f64) -> f64| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = coef_v(&n_at); + let d = coef_s(&d_at); + let got = conic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + // Taylor coefficients of P: P(t) and P⁽ʲ⁾(t)/j!. Leibniz on N = P·D then reads + // Σ xᵢ·dⱼ₋ᵢ = nⱼ at every order, which the quotient rule must reproduce. + let x: Vec = std::iter::once(conic.point_at(t)).chain(got.iter().enumerate().map(|(i, v)| *v / fact(i + 1))).collect(); + for j in 0..=4usize { + let lhs: Vec2 = (0..=j).fold(Vec2::ZERO, |acc, i| acc + x[i] * d.get(j - i).copied().unwrap_or(0.0)); + let rhs = n.get(j).copied().unwrap_or(Vec2::ZERO); + assert!(lhs.dist(rhs) < 1e-8 * rhs.len().max(1.0), "t={t} j={j}: {lhs:?} vs {rhs:?}"); + } + assert!(got[0].dist(conic.derivative(t)) < 1e-8, "t={t}"); + } + } + + #[test] + fn seg_derivatives_refuse_hostile_input_and_degenerate_pieces() { + let c = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::Y, p3: Vec2::X + Vec2::Y }; + assert!(c.derivatives(0.0, MAX_DERIVATIVE_ORDER + 1).is_none(), "no order above G4"); + assert!(c.derivatives(f64::NAN, 2).is_none()); + let nan = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; + assert!(nan.derivatives(0.0, 1).is_none()); + // A conic whose weight vanishes is a straight chord drawn at zero speed: its derivatives + // are finite, but the first is zero, which is what a blend refuses. + let flat = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 1.0), b: Vec2::new(2.0, 0.0), w: 0.0 }; + let d = flat.derivatives(0.0, 1).unwrap(); + assert_eq!(d[0], Vec2::ZERO); + } +} diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index bba7f847..fff56ea6 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -667,7 +667,7 @@ mod tests { assert!(degree_2(&conic(2.0), Some(vec![1.0, 2.0, 1.0]))); assert!(degree_2(&conic(1.0 - 1e-9), Some(vec![1.0, 1.0 - 1e-9, 1.0]))); // A quintic piece: six control points and twelve knots. - let mut q = [Vec2::ZERO; 8]; + let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, s) in q.iter_mut().enumerate() { *s = Vec2::new(i as f64, (i * i) as f64); } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 334f01f2..b80108a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -864,6 +864,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -948,6 +949,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 324250ba..a4bd1aa1 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1637,7 +1637,7 @@ fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool { } /// [`smooth_chain`], walking on only over edges `keep` allows. -fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { +pub(crate) fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let all = Body::unique_edges(solid); // Along the edge as it runs from its front to its back (an edge may run against its curve). @@ -1859,7 +1859,7 @@ fn fillet_built(body: &Body, edges: &[Vec3], radius: f64) -> Result { // trimmed by the face. Refused, the refusal above stands. .or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)).map_err(|_| e)) // Blends meeting at corners on curved faces: all at once. - .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) + .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet", false).and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) } /// The first refusal, unless only the second found the blend too large (a smaller one may fit). @@ -1872,8 +1872,9 @@ fn keep_size(e: KernelError, e2: KernelError) -> KernelError { } /// Blend the edges together, rebuilding the corners where they meet on faces of any surface -/// (see [`crate::curveblend::corner_blend`]). -fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { +/// (see [`crate::curveblend::corner_blend`]); with `g2`, the corners where three blends meet are +/// G2 patches ([`crate::corner_g2`]) instead of pieces of the ball's sphere. +fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, g2: bool) -> Result { let counts = record::counts(&[body]); let cur = crate::boolean_record::healed(body)?; let size = cur.size(); @@ -1882,10 +1883,27 @@ fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Res if ids.len() != edges.len() { return Err(KernelError::Invalid("no edge near a selected point".into())); } - let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?; + let (made, rec) = guard(what, || match g2 { + // The corner blend as it always was, bit for bit. + false => crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round), + true => crate::curveblend::corner_blend_style(&solid, &ids, size, r, shape == Shape::Round, true), + })?; Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts)) } +/// [`fillet`] whose corners where three blended edges meet are G2 patches: the corner repeats each +/// blend's surface up to the second order (see [`crate::corner_g2`]) instead of the rolling ball's +/// sphere. The corner construction is the all-at-once corner blend, so a configuration it cannot +/// hold is refused rather than quietly given the ball's sphere; [`fillet`] (the default) is +/// unchanged. +pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64) -> Result { + body.require_brep("fillet")?; + if !(radius.is_finite() && radius > 0.0) { + return Err(KernelError::Invalid("the blend radius must be finite and positive".into())); + } + corners(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)) +} + /// The step of the radius search: three significant digits of the requested radius. fn radius_step(radius: f64) -> f64 { 10f64.powf(radius.log10().floor() - 3.0) @@ -2191,7 +2209,7 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result { Ok(cur) }; // Chamfers meeting at corners on curved faces: all at once. - one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2))) + one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer", false).map_err(|e2| keep_size(e, e2))) } /// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left). diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index e0d7a495..8aafc19d 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -205,19 +205,35 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & } /// Fillet one straight edge with the radius `spline` along it, its positions from the end -/// nearest `start`. One end's radius may be zero. +/// nearest `start`. One end's radius may be zero. A curved edge, or one between curved faces, is +/// rolled by a ball of the law's radius instead ([`curved_variable`]). pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { body.require_brep("fillet")?; spline.check()?; let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; guard("variable fillet", || { - let s = EdgeSetup::new(body, &edge)?; + let Ok(s) = EdgeSetup::new(body, &edge) else { + return curved_variable(body, &edge, start, spline); + }; // Positions from the end nearest `start` (the edge keeps its own direction). let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() }; build(body, &s, &edge, &law) }) } +/// The radius law along a curved edge: a ball whose radius is the law's at each station rolls +/// along the edge's smooth chain (the same sections, contacts and trims as the straight-edge case, +/// sized by the law there). A law that has run to zero at an end closes the blend to the edge's +/// end point, as the straight-edge fillet does. +fn curved_variable(body: &Body, edge: &mt::Edge, start: Vec3, law: &RadiusSpline) -> Result { + // Positions from the end nearest `start` (the chain runs along the edge's own direction). + let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back())); + let law = if p1.dist(start) < p0.dist(start) { law.reversed() } else { law.clone() }; + let chain = crate::blend::smooth_chain(&body.solid, edge).unwrap_or_else(|| vec![edge.id()]); + let (made, rec) = guard("variable fillet", || crate::curveblend::curve_blend_law(&body.solid, &chain, body.size(), &law, true))?; + crate::record::rebuilt(made, &[&body.solid], &rec) +} + /// A straight edge between two planar faces as the variable blend sees it: the shell it is in /// and that shell's faces, the frame at the edge, and its ends (front first). struct EdgeSetup<'a> { @@ -570,8 +586,11 @@ pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Ok((cur, tapers)) } -/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend -/// is `chord` wide across; edges with the same face angle share a radius. +/// Chord-length fillet: each edge rounded so that the blend is `chord` wide across, from contact +/// to contact at every station. A straight edge between planar faces is built exactly, the radius +/// `chord / (2 cos(φ/2))` from its face angle and edges sharing that angle blended together; a +/// curved edge, or one between curved faces (or a chain of selected edges running on into one), +/// is rolled by a ball whose radius gives the same width, which follows the face angle along it. pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { body.require_brep("fillet")?; if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) { @@ -580,19 +599,44 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - // Radius per edge from its face angle. - let mut groups: Vec<(f64, Vec)> = Vec::new(); - for p in edges { - let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; - let shell = - body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?; - let faces: Vec<&mt::Face> = shell.face_iter().collect(); - let fr = edge_frame(&faces, &e)?; - let r = chord_radius(chord, fr.phi); - match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { - Some((_, v)) => v.push(*p), - None => groups.push((r, vec![*p])), + let found: Vec = edges + .iter() + .map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) + .collect::>()?; + // The exact radius of a straight edge between planar faces; None where the rolling ball has to + // take the edge (a curved edge, or one between curved faces). + let exact_radius = |e: &mt::Edge| -> Option { + if !matches!(e.curve(), mt::Curve::Line(_)) { + return None; } + let shell = body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id()))?; + let faces: Vec<&mt::Face> = shell.face_iter().collect(); + Some(chord_radius(chord, edge_frame(&faces, e).ok()?.phi)) + }; + // A straight edge running on smoothly into a selected curved one goes with it: the ball rolls + // along the chain, so blending the parts apart would leave a step at their joint. + let meets = |a: &mt::Edge, b: &mt::Edge| [a.front(), a.back()].into_iter().any(|v| v == b.front() || v == b.back()); + let mixed = |e: &mt::Edge| found.iter().any(|f| f.id() != e.id() && exact_radius(f).is_none() && meets(e, f)); + // Radius per edge from its face angle; the rest roll a ball along their chain. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + let mut rolled: Vec> = Vec::new(); + let mut done: Vec = Vec::new(); + for (p, e) in edges.iter().zip(&found) { + if let Some(r) = exact_radius(e).filter(|_| !mixed(e)) { + match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { + Some((_, v)) => v.push(*p), + None => groups.push((r, vec![*p])), + } + continue; + } + if done.contains(&e.id()) { + continue; + } + // The whole smooth chain (as the constant-radius path rolls it): a ball cannot stop + // where the edge runs on, so the chain's other edges are blended with it. + let chain = crate::blend::smooth_chain(&body.solid, e).unwrap_or_else(|| vec![e.id()]); + done.extend(chain.iter().copied()); + rolled.push(edges.iter().zip(&found).filter(|(_, f)| chain.contains(&f.id())).map(|(p, _)| *p).collect()); } // Each step's result carries its history over `[body]`. let counts = crate::record::counts(&[body]); @@ -600,6 +644,17 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { for (r, pts) in groups { cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts); } + for picks in rolled { + // The chain as the current body has it: the blends before it rebuilt the faces it runs + // through, and a rebuilt face carries its edges again. + let edges: Vec = picks.iter().filter_map(|p| crate::surfaces::edge_near(&cur, *p)).collect(); + let Some(first) = edges.first() else { continue }; + let keep = |x: &mt::Edge| edges.iter().any(|e| e.id() == x.id()); + let chain = crate::blend::smooth_chain_within(&cur.solid, first, &keep).unwrap_or_else(|| vec![first.id()]); + let (made, rec) = crate::guard("chord fillet", || crate::curveblend::curve_blend_chord(&cur.solid, &chain, cur.size(), chord, true))?; + let step = crate::record::rebuilt(made, &[&cur.solid], &rec)?; + cur = crate::record::after(step, &cur, &counts); + } Ok(cur) } diff --git a/crates/kernel/src/continuity.rs b/crates/kernel/src/continuity.rs new file mode 100644 index 00000000..001e3289 --- /dev/null +++ b/crates/kernel/src/continuity.rs @@ -0,0 +1,497 @@ +//! Continuity diagnostics across the seams between a body's faces: do the two faces that meet +//! at an edge share a curve (G0), a tangent plane (G1) and a curvature (G2)? +//! +//! The stations are taken on the edge itself — the curve both faces are supposed to share — so +//! the numbers describe the B-rep and not a tessellation: each station is projected onto each +//! face's own surface, and the two projections are compared. The gap is the distance between +//! them (G0); the normal angle is the angle between the faces' outward normals there (G1); the +//! curvature jump is the difference of their normal curvatures in the normal section square to +//! the edge, from the second fundamental form of each surface (G2). A rolling-ball fillet is +//! tangent to the faces it joins but is not curvature continuous: where it meets a plane its +//! seam reads 0° and 1 / radius, which no chord tolerance would reveal. +//! +//! [`crate::smooth_edges`] decides whether a join needs a *display* line; this module measures +//! the join, and backs the engine's `inspect.continuity`. + +use serde::Serialize; +use solidcraft_geom::Vec3; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3}; +use crate::{KernelError, Result}; + +/// Bodies with more edges than this are refused rather than sampled for minutes. +const MAX_EDGES: usize = 4096; +/// Most faces a body may have and still be analysed. +const MAX_FACES: usize = 50_000; +/// Stations one call may take over all its edges; edges past the budget are left out and +/// counted in [`ContinuityReport::skipped`]. +const MAX_STATIONS: usize = 200_000; +/// Most edges one pick list may name (the full list always fits the station budget). +const MAX_PICKS: usize = 512; +/// Samples used to find the edge nearest a picked point (as in `surfaces::edge_near`). +const PICK_SAMPLES: usize = 32; + +/// Least and most stations along one edge (`inspect.continuity`'s `samples`): three are the two +/// ends and the middle. +pub const MIN_SAMPLES: usize = 3; +pub const MAX_SAMPLES: usize = 257; + +/// How an edge is picked: a point on (or near) it, or its index in the display mesh's edge +/// order ([`Body::edges`]), which is what a viewport pick carries. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgePick { + Point(Vec3), + Index(usize), +} + +/// What counts as continuous across a seam. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContinuityTolerances { + /// Largest position gap that still counts as coincident (G0), mm. + pub gap_mm: f64, + /// Largest angle between the faces' normals that still counts as tangent (G1), degrees. + pub angle_deg: f64, + /// Largest curvature jump that still counts as curvature continuous (G2), 1/mm. + pub curvature_per_mm: f64, +} + +/// The worst value of one metric along an edge, with where it was found. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct SeamWorst { + pub value: f64, + /// The station that gave the worst value, in the body's coordinates. + pub at: Vec3, + /// How far along the edge it is: 0 at the edge's start, 1 at its end. + pub edge_fraction: f64, +} + +/// Which test a seam fails, from the best continuity to the worst: [`SeamVerdict::Smooth`] is +/// within every tolerance (G2 or better), then a tangent seam whose curvature jumps (G1), then +/// a visible crease (G0 only), and finally faces that do not meet at all. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SeamVerdict { + Smooth, + CurvatureJump, + Sharp, + Gap, +} + +/// One edge's diagnostics: the worst of each metric along it, and where. +#[derive(Clone, Debug, Serialize)] +pub struct SeamReport { + /// Index in the display mesh's edge order ([`Body::edges`]). + pub edge: usize, + /// Length of the edge, mm. + pub length: f64, + /// Stations the values were taken at. + pub samples: usize, + pub gap_mm: SeamWorst, + pub normal_angle_deg: SeamWorst, + pub curvature_jump_per_mm: SeamWorst, + /// What the tolerances make of the seam. + pub verdict: SeamVerdict, + /// The stations in order: what a viewport shades. + pub points: Vec, +} + +/// The seam diagnostics of one body. +#[derive(Clone, Debug, Serialize)] +pub struct ContinuityReport { + pub seams: Vec, + /// Edges left out: not a seam between two faces (an open boundary, or a periodic face's own + /// seam, which is one face meeting itself), or past the station budget. + pub skipped: usize, + /// Edges that could not be sampled at all: no station of theirs projected onto both faces. + pub unmeasured: usize, +} + +/// The edge indices `picks` mean, in display-mesh order and without repeats. A point pick takes +/// the edge nearest it (within the distance the kernel re-finds an edge from); an index pick +/// must be an existing index. An empty list means every two-sided seam of the body, and a pick +/// of an edge that is not a seam between two faces is refused, not guessed at. +pub fn pick_edges(body: &Body, picks: &[EdgePick]) -> Result> { + body.require_brep("continuity")?; + if picks.len() > MAX_PICKS { + return Err(KernelError::Invalid(format!("pick at most {MAX_PICKS} edges"))); + } + crate::guard("continuity picks", || { + let (edges, adjacent) = topology(body)?; + if picks.is_empty() { + return Ok((0..edges.len()).filter(|i| two_sided(&adjacent, *i)).collect()); + } + let reach = body.size() * 0.05 + 1e-6; + let mut out: Vec = Vec::new(); + for pick in picks { + let index = match *pick { + EdgePick::Index(index) => index, + EdgePick::Point(p) => nearest_edge(&edges, p, reach)?, + }; + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + if !out.contains(&index) { + out.push(index); + } + } + out.sort_unstable(); + Ok(out) + }) +} + +/// Sample the two-sided seams `edges` of `body` (indices in display-mesh order; empty: every +/// one of them) at `samples` stations along each edge's own parameter, the ends included, with +/// the tolerances the seams are judged against. +pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + body.require_brep("continuity")?; + if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) { + return Err(KernelError::Invalid(format!("`samples` must be {MIN_SAMPLES}…{MAX_SAMPLES}"))); + } + for (name, value) in [("gap", tolerances.gap_mm), ("angle", tolerances.angle_deg), ("curvature", tolerances.curvature_per_mm)] { + // A range test also throws out NaN (both comparisons are false). + if !(0.0..1e9).contains(&value) { + return Err(KernelError::Invalid(format!("the {name} tolerance must be a finite number, 0 or more"))); + } + } + crate::guard("continuity", || measure(body, edges, samples, tolerances)) +} + +/// The body's unique edges and the faces meeting at each of them, either under the caps. +fn topology(body: &Body) -> Result<(Vec, Vec>)> { + if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_FACES} faces"))); + } + let (edges, adjacent) = crate::body::edge_order(body.solid.face_iter()); + if edges.len() > MAX_EDGES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_EDGES} edges"))); + } + Ok((edges, adjacent)) +} + +/// Is the edge at `index` shared by (exactly) two faces of the body? +fn two_sided(adjacent: &[Vec], index: usize) -> bool { + adjacent.get(index).is_some_and(|faces| faces.len() == 2) +} + +/// The index of the edge whose curve comes nearest `p`, within `reach`. +fn nearest_edge(edges: &[mt::Edge], p: Vec3, reach: f64) -> Result { + use mt::ParametricCurve; + let mut best: Option<(f64, usize)> = None; + for (index, edge) in edges.iter().enumerate() { + let curve = edge.curve(); + let Some((t0, t1)) = curve.try_range_tuple() else { continue }; + if !(t0.is_finite() && t1.is_finite() && t1 > t0) { + continue; + } + for k in 0..=PICK_SAMPLES { + let q = from_p3(curve.subs(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64)); + let d = q.dist(p); + if d.is_finite() && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, index)); + } + } + } + match best { + Some((d, index)) if d <= reach => Ok(index), + _ => Err(KernelError::Invalid(format!("no edge of the body is within {reach:.3} mm of ({}, {}, {})", p.x, p.y, p.z))), + } +} + +fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + let (list, adjacent) = topology(body)?; + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + let mut selected: Vec = Vec::new(); + let mut skipped = 0usize; + if edges.is_empty() { + for index in 0..list.len() { + if two_sided(&adjacent, index) { + selected.push(index); + } else { + skipped += 1; + } + } + } else { + selected.extend(edges.iter().copied()); + selected.sort_unstable(); + selected.dedup(); + for &index in &selected { + if index >= list.len() { + return Err(KernelError::Invalid(format!("the body has no edge {index}"))); + } + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + } + } + let mut seams = Vec::new(); + let mut unmeasured = 0usize; + let mut budget = MAX_STATIONS; + for index in selected { + let Some(face_pair) = adjacent.get(index).filter(|faces| faces.len() == 2) else { continue }; + let (Some(&first), Some(&second)) = (face_pair.first(), face_pair.get(1)) else { continue }; + let (Some(a), Some(b)) = (faces.get(first).copied(), faces.get(second).copied()) else { continue }; + let Some(edge) = list.get(index) else { continue }; + // Every edge costs `samples` stations; a run out of budget stops cleanly. + if budget < samples { + skipped += 1; + continue; + } + budget -= samples; + match seam(index, edge, a, b, samples, tolerances) { + Some(report) => seams.push(report), + None => unmeasured += 1, + } + } + Ok(ContinuityReport { seams, skipped, unmeasured }) +} + +/// One edge's worst gap, normal angle and curvature jump. +fn seam(index: usize, edge: &mt::Edge, face_a: &mt::Face, face_b: &mt::Face, samples: usize, tolerances: ContinuityTolerances) -> Option { + use mt::ParametricCurve; + let curve = edge.curve(); + let (t0, t1) = curve.try_range_tuple()?; + // The station fractions: 0 at the edge's start, 1 at its end. + let steps = samples.checked_sub(1)?; + let fraction = |k: usize| k as f64 / steps as f64; + let mut on_a = FaceSurface::new(face_a); + let mut on_b = FaceSurface::new(face_b); + let mut gap = None; + let mut angle = None; + let mut jump = None; + let mut points = Vec::with_capacity(samples); + for k in 0..samples { + let f = fraction(k); + let at = from_p3(curve.subs(t0 + (t1 - t0) * f)); + if !at.is_finite() { + continue; + } + // Both projections and both curvatures, or the station says nothing. + let (Some((qa, na, uv_a)), Some((qb, nb, uv_b))) = (on_a.project(at), on_b.project(at)) else { continue }; + let tangent = curve.der(t0 + (t1 - t0) * f); + let Some(tangent) = Vec3::new(tangent.x, tangent.y, tangent.z).normalized() else { continue }; + // The normal sections square to the seam: one direction per face, in its tangent plane. + let (Some(ka), Some(kb)) = (on_a.normal_curvature(uv_a, na, across(na, tangent)), on_b.normal_curvature(uv_b, nb, across(nb, tangent))) + else { + continue; + }; + // The faces' outward normals agree on a smooth seam; where they are opposed (a knife + // edge) the curvatures are compared in one frame, since bending away from one face is + // bending towards the other. + let signed = if na.dot(nb) < 0.0 { ka + kb } else { ka - kb }; + points.push(at); + keep(&mut gap, qa.dist(qb), at, f); + keep(&mut angle, na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), at, f); + keep(&mut jump, signed.abs(), at, f); + } + let (gap, angle, jump) = (gap?, angle?, jump?); + let verdict = if gap.value > tolerances.gap_mm { + SeamVerdict::Gap + } else if angle.value > tolerances.angle_deg { + SeamVerdict::Sharp + } else if jump.value > tolerances.curvature_per_mm { + SeamVerdict::CurvatureJump + } else { + SeamVerdict::Smooth + }; + let length = points.windows(2).map(|w| w.first().zip(w.get(1)).map_or(0.0, |(a, b)| a.dist(*b))).sum(); + Some(SeamReport { + edge: index, + length, + samples: points.len(), + gap_mm: gap, + normal_angle_deg: angle, + curvature_jump_per_mm: jump, + verdict, + points, + }) +} + +/// The direction in which a face's curvature is read at a seam: across the edge, square to it, +/// inside the face's tangent plane (a zero vector where the edge runs along the normal, which +/// leaves that station unmeasured). +fn across(normal: Vec3, tangent: Vec3) -> Vec3 { + normal.cross(tangent) +} + +/// Keep the largest value seen, with where it was found. +fn keep(worst: &mut Option, value: f64, at: Vec3, edge_fraction: f64) { + if value.is_finite() && worst.is_none_or(|w| value > w.value) { + *worst = Some(SeamWorst { value, at, edge_fraction }); + } +} + +/// A face's surface, kept over the stations of one edge so each search can start at the last +/// answer (stations run along the edge, so the parameters move smoothly). +struct FaceSurface { + surface: mt::Surface, + hint: Option<(f64, f64)>, +} + +impl FaceSurface { + /// The face's own outward-facing surface (`oriented_surface`: a reversed face's surface is + /// inverted, so its normal is the material's outward normal). + fn new(face: &mt::Face) -> Self { + Self { surface: face.oriented_surface(), hint: None } + } + + /// The nearest point of the surface to `p`, the outward unit normal there and the + /// parameters. `None` where the search or the normal is unusable. + fn project(&mut self, p: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let uv = self.surface.search_nearest_parameter(p3(p), self.hint, 100).or_else(|| self.surface.search_nearest_parameter(p3(p), None, 100))?; + if !(uv.0.is_finite() && uv.1.is_finite()) { + return None; + } + let q = from_p3(self.surface.subs(uv.0, uv.1)); + let n = self.surface.normal(uv.0, uv.1); + if !q.is_finite() { + return None; + } + let normal = Vec3::new(n.x, n.y, n.z).normalized()?; + self.hint = Some(uv); + Some((q, normal, uv)) + } + + /// The normal curvature of the face at `uv` along `direction` (a unit vector of its tangent + /// plane), from the second fundamental form. A surface point `r(u, v)` with unit normal `n` + /// crossed that way has curvature `κ = n · r''` of the curve whose tangent there is + /// `direction`: a plane reads 0 and a cylinder of radius `R`, across its axis, −1 / R + /// (bending towards its axis). `None` at a degenerate parameter point (a cone's apex), where + /// the surface has no curvature. + fn normal_curvature(&self, uv: (f64, f64), normal: Vec3, direction: Vec3) -> Option { + use mt::ParametricSurface; + let ru = vec3(self.surface.uder(uv.0, uv.1)); + let rv = vec3(self.surface.vder(uv.0, uv.1)); + let (ru_len, rv_len) = (ru.len(), rv.len()); + if !(ru_len > 0.0 && rv_len > 0.0) { + return None; + } + // Solve the direction in the unit basis (r_u / |r_u|, r_v / |r_v|): the curve then runs + // its parameters at du/ds = α / |r_u| and dv/ds = β / |r_v|, and + // `r'' = r_uu (du/ds)² + 2 r_uv (du/ds)(dv/ds) + r_vv (dv/ds)²`. + let cos = (ru.dot(rv) / (ru_len * rv_len)).clamp(-1.0, 1.0); + let sin2 = 1.0 - cos * cos; + if !(sin2 > 1e-12) { + return None; + } + let (cu, cv) = (direction.dot(ru) / ru_len, direction.dot(rv) / rv_len); + let (du, dv) = ((cu - cv * cos) / sin2 / ru_len, (cv - cu * cos) / sin2 / rv_len); + if !(du.is_finite() && dv.is_finite()) { + return None; + } + // `direction` is a unit vector: divide by the speed the parameters imply (they only miss + // it by rounding), so κ does not depend on how the surface is parameterized. + let speed = (ru * du + rv * dv).len(); + if !(speed > 1e-6) { + return None; + } + let second = normal.dot( + vec3(self.surface.uuder(uv.0, uv.1)) * (du * du) + + vec3(self.surface.uvder(uv.0, uv.1)) * (2.0 * du * dv) + + vec3(self.surface.vvder(uv.0, uv.1)) * (dv * dv), + ); + (second / (speed * speed)).is_finite().then_some(second / (speed * speed)) + } +} + +/// A kernel point as a vector. +fn vec3(p: mt::Vector3) -> Vec3 { + Vec3::new(p.x, p.y, p.z) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use solidcraft_geom::Plane; + + fn tolerances() -> ContinuityTolerances { + ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 } + } + + fn box_body() -> Body { + crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap() + } + + fn report(body: &Body) -> ContinuityReport { + seam_diagnostics(body, &[], 33, tolerances()).unwrap() + } + + #[test] + fn a_box_edge_is_a_gap_free_right_angle() { + let body = box_body(); + let r = report(&body); + assert_eq!(r.seams.len(), 12, "a box has twelve edges"); + assert_eq!(r.skipped, 0); + for seam in &r.seams { + assert!(seam.gap_mm.value < 1e-6, "gap {}", seam.gap_mm.value); + assert!((seam.normal_angle_deg.value - 90.0).abs() < 1e-9, "angle {}", seam.normal_angle_deg.value); + assert!(seam.curvature_jump_per_mm.value < 1e-9, "jump {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::Sharp); + assert_eq!(seam.samples, 33); + assert!((seam.length - 10.0).abs() < 1e-9); + } + } + + #[test] + fn a_fillet_seam_is_tangent_with_the_fillets_curvature_jump() { + let body = box_body(); + let rounded = crate::fillet(&body, &[Vec3::new(10.0, 10.0, 5.0)], 4.0).unwrap(); + let r = report(&rounded); + // The rounded edge is replaced by the blend face: two tangent seams along it, the two + // arcs where the blend meets the ends of the edge, and the box's other eleven edges. + assert_eq!(r.seams.len(), 15); + let tangent: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(tangent.len(), 2, "the fillet meets its two neighbours tangentially"); + for seam in &tangent { + assert!(seam.gap_mm.value < 1e-6); + assert!(seam.curvature_jump_per_mm.value.is_finite()); + assert!((seam.curvature_jump_per_mm.value - 0.25).abs() < 1e-6, "1 / radius, got {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::CurvatureJump); + } + assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13); + } + + #[test] + fn a_line_tangent_into_an_arc_reports_one_over_its_radius() { + use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; + let v = |x: f64, y: f64| Vec2::new(x, y); + let outer = Loop2 { + segs: vec![ + Seg2::Arc { center: v(10.0, 0.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(10.0, 5.0), b: v(-10.0, 5.0) }, + Seg2::Arc { center: v(-10.0, 0.0), radius: 5.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(-10.0, -5.0), b: v(10.0, -5.0) }, + ], + }; + let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0); + let r = report(&body); + let joins: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(joins.len(), 4, "a slot has four line-to-arc joins"); + for seam in joins { + assert!( + seam.curvature_jump_per_mm.value > 0.19 && seam.curvature_jump_per_mm.value < 0.21, + "1 / 5, got {}", + seam.curvature_jump_per_mm.value + ); + } + } + + #[test] + fn picks_refuse_what_is_not_there() { + let body = box_body(); + let all = pick_edges(&body, &[]).unwrap(); + assert_eq!(all.len(), 12); + // The edge up the corner at (0, 0), beside a point in the middle of a face. + let picked = pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 0.0, 5.0)), EdgePick::Index(all[0])]).unwrap(); + assert_eq!(picked.len(), 2); + assert!(pick_edges(&body, &[EdgePick::Index(999)]).is_err()); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 5.0, 5.0))]).is_err(), "a point on a face is not on an edge"); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(500.0, 0.0, 0.0))]).is_err()); + assert!(seam_diagnostics(&body, &[], 2, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 258, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: f64::NAN, ..tolerances() }).is_err()); + assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err()); + } +} diff --git a/crates/kernel/src/corner_g2.rs b/crates/kernel/src/corner_g2.rs new file mode 100644 index 00000000..22e236df --- /dev/null +++ b/crates/kernel/src/corner_g2.rs @@ -0,0 +1,501 @@ +//! G2 corner patches: the patch that fills a vertex where filleted edges meet, built to repeat each +//! blend's surface up to the second order instead of the rolling ball's sphere. +//! +//! [`crate::blend_corner`] and [`crate::curvecorner`] fill the corner of a vertex with a piece of +//! the ball that rolls along the three edges (a sphere for equal radii, a rational patch for +//! unequal ones). That patch is tangent to every blend along the sections where they stop, so the +//! corner is G1 — but across a seam the ball keeps its own 1/r where a fillet is flat along its +//! axis, so the two surfaces meet with a jump in curvature. +//! +//! Here the corner is one tensor patch whose boundary is the blends' section curves themselves and +//! whose cross-sections carry each blend's own derivatives: the position, the direction the patch +//! leaves the blend's surface in, and the blend's curvature vector along that direction are the +//! first three control points of every cross-section, one control point per continuity condition, +//! exactly as [`crate::bridge`]'s sections do. The other end of every cross-section gathers at +//! `apex` — a point inside the material the corner caps — where the sections arrive in a common +//! tangent plane with a common curvature, so the patch closes there smoothly like a sphere's pole. +//! Along a seam the patch therefore repeats the blend up to the second order (G2). +//! +//! The boundary is a closed loop (the three sections joined corner to corner), so the direction +//! along it is a *closed* cubic B-spline: an interpolating curve with free ends swings wide at the +//! corner where the loop joins and folds the patch there. Where two blends meet at that corner +//! their sections leave in directions 90° apart and ask for different curvature at that one point +//! (a fillet is flat across its own seam, the other is not), which no single surface can hold; the +//! corner is a still point of the construction and [`seam_error`] is measured clear of it. A patch +//! that folds, or a corner with no room, is refused rather than replaced by the G1 ball. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{HPoint, NurbsSurface}; +use truck_modeling::{self as mt, builder}; + +use crate::blend::unsupported; +use crate::body::{from_p3, p3}; +use crate::bridge::Frame; +use crate::{KernelError, Result, guard}; + +/// A corner's name in an error. +fn fail(m: impl std::fmt::Display) -> KernelError { + KernelError::Failed(format!("G2 corner: {m}")) +} + +/// One of the surface's own derivative vectors as ours. +fn vec_of(v: mt::Vector3) -> Vec3 { + Vec3::new(v.x, v.y, v.z) +} + +/// Conditions each end of a cross-section carries: position, tangent and curvature. +const END_CONDITIONS: usize = 3; +/// Degree across the patch: three conditions at each end, less the control point they share. +const ACROSS: usize = 2 * END_CONDITIONS - 1; +/// Degree along the seam loop. +const ALONG: usize = 3; +/// Fewest stations a section curve may be sampled at (a cubic through them). +const MIN_STATIONS: usize = 4; +/// Most stations one section curve may carry. +const MAX_STATIONS: usize = 512; +/// How much of a station's `leave` has to lie across its seam for the patch to have a direction to +/// leave the blend in at all. +const ACROSS_ENOUGH: f64 = 0.2; + +/// The G2 corner patch over three blends' section curves: `sides[i]` is one blend's section curve, +/// sampled in order from one corner of the vertex to the next, each side's last station the next +/// side's first (the three close a loop). `apex` is a point inside the material the corner caps; +/// the patch gathers there. The face's boundary edges are the section curves themselves (one edge +/// per side, cut out of the patch's own boundary curve, so they lie on it exactly); the caller +/// re-wraps the wire where it needs its own orientation. +pub fn corner_g2_patch(sides: &[Vec], apex: Vec3) -> Result> { + guard("G2 corner patch", || build(sides, apex)) +} + +/// The seam's own direction at station `i` of a side, from a four-point central difference through +/// the stations (`crate::bridge::frames` samples a chain evenly, and a corner's own callers do the +/// same): a two-point chord would only be second order in the step and would tilt the corner's +/// tangent plane by more than G2 is judged at. `None` within two stations of a side's end. +fn seam_tangent(side: &[Frame], i: usize) -> Option { + let at = |j: isize| usize::try_from(j).ok().and_then(|j| side.get(j)).map(|f| f.p); + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() +} + +/// The seam's direction at every station of the closed loop (the same difference, wrapping round +/// its ends), falling back to the chord to a neighbour where the four points are not there. +fn loop_tangents(frames: &[Frame]) -> Vec> { + let m = frames.len(); + if m == 0 { + return Vec::new(); + } + let at = |j: isize| frames.get(j.rem_euclid(m as isize) as usize).map(|f| f.p); + (0..m) + .map(|i| { + let four = || { + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() + }; + four().or_else(|| { + let prev = at(i as isize - 1)?; + let next = at(i as isize + 1)?; + (next - prev).normalized() + }) + }) + .collect() +} + +/// The three section curves as one closed loop of stations, the loop index each side starts at, +/// and the corner's own size (twice the mean distance of a station from their centre). +fn corner_loop(sides: &[Vec]) -> Result<(Vec, Vec, f64)> { + if sides.len() != 3 { + return Err(unsupported("a G2 corner needs three blends meeting at one vertex (a corner of two, or one, still ends on the ball's patch)")); + } + let mut frames: Vec = Vec::new(); + let mut starts: Vec = Vec::new(); + for side in sides { + if side.len() < MIN_STATIONS || side.len() > MAX_STATIONS { + return Err(KernelError::Invalid(format!("a corner section curve needs {MIN_STATIONS} to {MAX_STATIONS} sampled stations"))); + } + for f in side { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) || !(f.leave.len() > 0.5) { + return Err(KernelError::Invalid("a corner station must carry finite data and a unit direction".into())); + } + } + starts.push(frames.len()); + // A side runs from one corner to the next; its last station is the next side's first, so the + // loop takes it once. + frames.extend(side.iter().take(side.len() - 1).copied()); + } + let centre = frames.iter().fold(Vec3::ZERO, |a, f| a + f.p) * (1.0 / frames.len().max(1) as f64); + let size = frames.iter().map(|f| f.p.dist(centre)).fold(0.0, f64::max) * 2.0; + if !(size > 0.0 && size.is_finite()) { + return Err(unsupported("a corner whose section curves have no extent")); + } + for (i, side) in sides.iter().enumerate() { + let Some(prev) = sides.get((i + sides.len() - 1) % sides.len()).and_then(|s| s.last()) else { continue }; + let Some(&here) = side.first() else { continue }; + if prev.p.dist(here.p) > size * 1e-3 { + return Err(unsupported("the section curves do not meet at a corner")); + } + } + Ok((frames, starts, size)) +} + +/// Every station's `leave` squared up with the seam it belongs to: the part of it across the seam, +/// which is the direction the cross-section has to leave the blend in. A `leave` along the seam +/// itself leaves the cross-section no room, and is refused. +fn transverse(frames: &[Frame], tangents: &[Option]) -> Result> { + let mut out = Vec::with_capacity(frames.len()); + for (f, t) in frames.iter().zip(tangents) { + let across = match t { + Some(t) => f.leave - *t * f.leave.dot(*t), + // Stations that offer no direction along the seam: the leave stands as it came. + None => f.leave, + }; + if across.len() < ACROSS_ENOUGH * f.leave.len() { + return Err(unsupported("a section curve meets the corner patch along its own tangent, so the patch has no direction to leave it in")); + } + let leave = across.normalized().ok_or_else(|| unsupported("a corner station has no direction across its seam"))?; + out.push(Frame { leave, ..*f }); + } + Ok(out) +} + +/// One station's cross-section: `ACROSS + 1` control points of a Bézier from the seam point to +/// `apex`. The first three hold the blend: its position, the direction the patch leaves it in +/// (one control point along `leave`) and its curvature vector across the seam (one more). The last +/// three hold the apex: the point, a tangent in the apex's own plane, and the curvature of a dome +/// over the corner, so the sections arrive smoothly instead of leaving a point. +fn section(f: &Frame, apex: Vec3, n_apex: Vec3, kappa: f64) -> Result<[Vec3; ACROSS + 1]> { + let k = ACROSS as f64; + let chord = f.p.dist(apex); + if !(chord.is_finite() && chord > 0.0) { + return Err(unsupported("a blend too small for the corner patch to fit")); + } + let speed = chord / 3.0; + let to_apex = (f.p - apex).normalized().ok_or_else(|| unsupported("a section curve starts on the apex"))?; + let leave_apex = + (to_apex - n_apex * to_apex.dot(n_apex)).normalized().ok_or_else(|| unsupported("a section curve reaches the apex along the apex normal"))?; + let mut c = [Vec3::ZERO; ACROSS + 1]; + c[0] = f.p; + c[1] = f.p + f.leave * (speed / k); + c[2] = f.bend * (speed * speed / (k * (k - 1.0))) + c[1] * 2.0 - c[0]; + c[ACROSS] = apex; + c[ACROSS - 1] = apex + leave_apex * (speed / k); + c[ACROSS - 2] = (-n_apex * kappa) * (speed * speed / (k * (k - 1.0))) + c[ACROSS - 1] * 2.0 - c[ACROSS]; + if c.iter().any(|p| !p.is_finite()) { + return Err(fail("a cross-section of the corner is not finite")); + } + Ok(c) +} + +/// A closed uniform cubic B-spline through `points`, once round the loop: the curve at each station +/// is `(q[i-1] + 4 q[i] + q[i+1]) / 6`, so the stations fix the net by a cyclic tridiagonal system, +/// solved here with the Sherman–Morrison form of the Thomas algorithm (a corner's loop runs to +/// hundreds of stations). The net comes back in the open form the tensor surface wants — the first +/// three control points repeated past the end — so it still closes, with one uniform span per +/// station and no free end anywhere. +fn closed_layer(points: &[Vec3]) -> Result> { + let m = points.len(); + if m < 2 * ALONG + 2 { + return Err(fail("a corner loop needs at least eight stations")); + } + // Sub-diagonal 1, diagonal 4, super-diagonal 1, and 1 in both corners. + let (sub, diag, sup, corner) = (1.0f64, 4.0f64, 1.0f64, 1.0f64); + let gamma = -diag; + let mut bb = vec![diag; m]; + if let Some(first) = bb.first_mut() { + *first = diag - gamma; + } + if let Some(last) = bb.last_mut() { + *last = diag - corner * corner / gamma; + } + // Thomas, one right-hand side at a time (three components solved together). + let thomas = |rhs: &[Vec3]| -> Result> { + let mut c = vec![0.0f64; m]; + let mut d = rhs.to_vec(); + let mut denom = bb.first().copied().unwrap_or(0.0); + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + let (Some(c0), Some(d0)) = (c.first_mut(), d.first_mut()) else { return Err(fail("stations")) }; + *c0 = sup / denom; + *d0 = *d0 * (1.0 / denom); + for i in 1..m { + let (prev_c, prev_d) = (c.get(i - 1).copied().unwrap_or(0.0), d.get(i - 1).copied().unwrap_or(Vec3::ZERO)); + let b = bb.get(i).copied().unwrap_or(diag); + denom = b - sub * prev_c; + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + if let Some(slot) = c.get_mut(i) { + *slot = if i + 1 < m { sup / denom } else { 0.0 }; + } + if let Some(slot) = d.get_mut(i) { + *slot = (*slot - prev_d * sub) * (1.0 / denom); + } + } + let mut x = vec![Vec3::ZERO; m]; + let last = d.last().copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.last_mut() { + *slot = last; + } + for i in (0..m - 1).rev() { + let (Some(ci), Some(xn)) = (c.get(i).copied(), x.get(i + 1).copied()) else { continue }; + let di = d.get(i).copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.get_mut(i) { + *slot = di - xn * ci; + } + } + Ok(x) + }; + let rhs: Vec = points.iter().map(|p| *p * 6.0).collect(); + let x = thomas(&rhs)?; + // The unit right-hand side the corner terms add: gamma at the first station, the corner at the + // last. + let mut unit = vec![Vec3::ZERO; m]; + if let Some(slot) = unit.first_mut() { + *slot = Vec3::new(gamma, gamma, gamma); + } + if let Some(slot) = unit.last_mut() { + *slot = Vec3::new(corner, corner, corner); + } + let z = thomas(&unit)?; + let (x0, xn) = (x.first().copied().unwrap_or(Vec3::ZERO), x.last().copied().unwrap_or(Vec3::ZERO)); + let (z0, zn) = (z.first().copied().unwrap_or(Vec3::ZERO), z.last().copied().unwrap_or(Vec3::ZERO)); + // z answers a scalar right-hand side, so the correction is one scalar factor per component. + let factor = |x0: f64, xn: f64, z0: f64, zn: f64| (x0 + corner * xn / gamma) / (1.0 + z0 + corner * zn / gamma); + let f = Vec3::new(factor(x0.x, xn.x, z0.x, zn.x), factor(x0.y, xn.y, z0.y, zn.y), factor(x0.z, xn.z, z0.z, zn.z)); + let mut out = Vec::with_capacity(m + ALONG); + for i in 0..m { + let (xi, zi) = (x.get(i).copied().unwrap_or(Vec3::ZERO), z.get(i).copied().unwrap_or(Vec3::ZERO)); + out.push(xi - Vec3::new(f.x * zi.x, f.y * zi.y, f.z * zi.z)); + } + if !out.iter().all(|p| p.is_finite()) { + return Err(fail("the corner's boundary is not finite")); + } + for i in 0..ALONG { + let p = out.get(i).copied().unwrap_or(Vec3::ZERO); + out.push(p); + } + Ok(out) +} + +/// The stations of the loop as one tensor control net: across (v) each station's cross-section is +/// the Bézier above, along the seam loop (u) each of its control points is a closed cubic through +/// the stations, so the face passes through every section exactly and its parameter line along the +/// loop closes without an end to swing from. The loop's station count comes back with it: station +/// `i` sits at parameter `i + ALONG`, and the loop closes at `count + ALONG`. +fn tensor(layers: &[[Vec3; ACROSS + 1]]) -> Result<(NurbsSurface, f64)> { + let m = layers.len(); + let nu = m + ALONG; + let knots_u: Vec = (0..=(nu + ALONG) as i64).map(|i| i as f64).collect(); + let mut ctrl: Vec = Vec::with_capacity(nu * (ACROSS + 1)); + for j in 0..=ACROSS { + let points: Vec = layers.iter().map(|c| c.get(j).copied().unwrap_or(Vec3::ZERO)).collect(); + let net = closed_layer(&points)?; + for p in &net { + ctrl.push(HPoint::weighted(*p, 1.0)); + } + } + let mut knots_v = vec![0.0; ACROSS + 1]; + knots_v.extend(std::iter::repeat_n(1.0, ACROSS + 1)); + let s = NurbsSurface::from_homogeneous([ALONG, ACROSS], [knots_u, knots_v], ctrl).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok((s, m as f64)) +} + +/// The surface across its own domain: the patch must keep facing one way, or a blend would be met +/// by a piece of the patch folded back on itself. +fn no_fold(surface: &mt::Surface, loop_range: (f64, f64)) -> Result<()> { + use mt::ParametricSurface; + // The u knots run past the loop at both ends (a closed curve has no clamped ends to name), so + // the loop's own range is brought in rather than read off the knot vector. + let (u0, u1) = loop_range; + let Some((v0, v1)) = surface.try_range_tuple().1 else { + return Err(fail("the corner patch has no parameter domain")); + }; + let normal = |u: f64, v: f64| -> Option { vec_of(surface.uder(u, v)).cross(vec_of(surface.vder(u, v))).normalized() }; + let mid = normal((u0 + u1) * 0.5, v0 + (v1 - v0) * 0.5).ok_or_else(|| unsupported("the corner patch has no normal at its middle"))?; + // Clear of the apex (v1), where the patch's parameter line along the seam collapses to a point + // and the normal is read from the sections approaching it. + for i in 0..=8 { + for j in 0..9 { + let u = u0 + (u1 - u0) * i as f64 / 8.0; + let v = v0 + (v1 - v0) * j as f64 / 9.0; + let n = normal(u, v).ok_or_else(|| unsupported("the corner patch folds back on itself"))?; + if n.dot(mid) <= 0.0 { + return Err(unsupported("the corner patch folds back on itself with these section curves")); + } + } + } + Ok(()) +} + +/// Build the patch: sections, tensor surface, and the wire of the section curves themselves. +fn build(sides: &[Vec], apex: Vec3) -> Result> { + if !apex.is_finite() { + return Err(KernelError::Invalid("the corner apex must be a finite point".into())); + } + let (frames, starts, size) = corner_loop(sides)?; + let frames = transverse(&frames, &loop_tangents(&frames))?; + // The apex's own plane: the mean direction from the apex to the boundary it caps. + let n_apex = frames + .iter() + .fold(Vec3::ZERO, |a, f| a + (f.p - apex)) + .normalized() + .ok_or_else(|| unsupported("the corner's apex sits on its own boundary curves"))?; + // How hard the patch bends into the apex: as much as its blends do across their seams, and + // never flatter than a dome of the corner's own size (a flat cap would end in a point). + let mean_bend = frames.iter().map(|f| f.bend.len()).sum::() / frames.len().max(1) as f64; + let reach = (frames.iter().map(|f| f.p.dist(apex)).sum::() / frames.len().max(1) as f64).max(size * 1e-3); + let kappa = mean_bend.max(1.0 / (2.0 * reach)); + if !(kappa.is_finite() && kappa > 0.0) { + return Err(unsupported("a corner the patch cannot span")); + } + let layers: Vec<[Vec3; ACROSS + 1]> = frames.iter().map(|f| section(f, apex, n_apex, kappa)).collect::>()?; + let (nurbs, m) = tensor(&layers)?; + let surface = crate::spline_conv::surface(&nurbs)?; + no_fold(&surface, (ALONG as f64, m + ALONG as f64))?; + // The boundary is the loop of section curves; each side is cut out of the patch's own boundary + // curve so that it lies on the patch exactly. + let nu = nurbs.count().first().copied().unwrap_or(0); + let row: Vec = (0..nu).map(|i| nurbs.point(i, 0)).collect(); + let Some(knots_u) = nurbs.knots().first().cloned() else { return Err(fail("knots")) }; + let loop_curve = crate::spline_conv::nurbs_curve(mt::KnotVec::from(knots_u), &row)?; + let mut cuts: Vec = starts.iter().map(|i| *i as f64 + ALONG as f64).collect(); + cuts.push(m + ALONG as f64); + let mut verts: Vec = Vec::with_capacity(sides.len()); + for side in sides { + let first = side.first().ok_or_else(|| fail("a section curve has no stations"))?; + verts.push(builder::vertex(p3(first.p))); + } + let mut edges: Vec = Vec::with_capacity(sides.len()); + for i in 0..sides.len() { + let (Some(&a), Some(&b)) = (cuts.get(i), cuts.get(i + 1)) else { return Err(fail("the corner's seams")) }; + let mut piece = loop_curve.clone(); + let _tail = mt::Cut::cut(&mut piece, b); + let piece = mt::Cut::cut(&mut piece, a); + let (Some(from), Some(to)) = (verts.get(i), verts.get((i + 1) % verts.len())) else { return Err(fail("the corner's vertices")) }; + edges.push(mt::Edge::try_new(from, to, piece).map_err(|e| fail(format!("a corner seam: {e}")))?); + } + let face = mt::Face::try_new(vec![mt::Wire::from(edges)], surface).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok(vec![face]) +} + +/// What the patch achieves along the seams of `sides`: the largest distance from a seam station to +/// the patch, the largest angle (radians) between the patch's normal there and the blend's, and the +/// largest difference between the curvature across the seam and the blend's own, relative to +/// `reference` — the curvature a rolling-ball corner shows across the same seam, which is what G2 +/// removes. Measured on the built surface alone, by the same finite differences of its own samples +/// that `crate::bridge`'s tests read a cross-section's shape with, so it is evidence about the +/// geometry and not about the construction. +pub fn seam_error(surface: &mt::Surface, sides: &[Vec], reference: f64) -> Result<(f64, f64, f64)> { + use mt::{ParametricSurface, SearchNearestParameter}; + if !(reference.is_finite() && reference > 0.0) { + return Err(KernelError::Invalid("a reference curvature must be finite and positive".into())); + } + let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { + return Err(fail("the corner patch has no parameter domain")); + }; + // Samples across the section for a five-point difference, as `bridge`'s tests use. + const INWARDS: usize = 5; + let hv = (v1 - v0).abs() * 1e-3; + let hu = (u1 - u0).abs() * 1e-3; + if !(hv > 0.0 && hu > 0.0) { + return Err(fail("the corner patch has no extent to measure")); + } + let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, 0.0f64); + for side in sides { + if side.len() < 5 { + return Err(KernelError::Invalid("a seam needs at least five stations to measure its direction".into())); + } + let mut hint: Option<(f64, f64)> = None; + for (i, f) in side.iter().enumerate() { + let Some(t) = seam_tangent(side, i) else { continue }; + let found = surface + .search_nearest_parameter(p3(f.p), hint, 100) + .or_else(|| surface.search_nearest_parameter(p3(f.p), None, 200)) + .ok_or_else(|| fail("a seam station is not on the corner patch"))?; + hint = Some(found); + let (u, v) = found; + let at = from_p3(surface.subs(u, v)); + gap = gap.max(at.dist(f.p)); + // The cross-section, sampled inwards from the seam (or from the other end of the patch, + // with the odd differences turned round). + let out = v <= (v0 + v1) * 0.5; + let step = if out { hv } else { -hv }; + let q: Vec = (0..INWARDS).map(|k| from_p3(surface.subs(u, v + step * k as f64))).collect(); + let (Some(&f0), Some(&f1), Some(&f2), Some(&f3), Some(&f4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else { + return Err(fail("the corner patch has no room for a sample")); + }; + let sign = if out { 1.0 } else { -1.0 }; + let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (sign / (12.0 * hv)); + let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * hv * hv)); + // Along the seam, to stand the tangent plane up. + let du = if u - hu >= u0 && u + hu <= u1 { + (from_p3(surface.subs(u + hu, v)) - from_p3(surface.subs(u - hu, v))) * (0.5 / hu) + } else { + let (a, b) = if u + hu <= u1 { (u, u + hu) } else { (u, u - hu) }; + (from_p3(surface.subs(b, v)) - from_p3(surface.subs(a, v))) * (1.0 / hu) + }; + let n = du.cross(d1).normalized().ok_or_else(|| fail("the corner patch has no normal at a seam"))?; + let blend_n = t.cross(f.leave).normalized().ok_or_else(|| fail("a blend has no normal at a seam"))?; + angle = angle.max(n.dot(blend_n).abs().clamp(0.0, 1.0).acos()); + // The curvature vector of the cross-seam curve: what the blend's own surface does along + // the direction the patch leaves it in. + let speed2 = d1.len2().max(1e-300); + let bend = (d2 - d1 * (d2.dot(d1) / speed2)) * (1.0 / speed2); + curvature = curvature.max((bend - f.bend).len() / reference.max(f.bend.len()).max(1e-30)); + } + } + Ok((gap, angle, curvature)) +} + +#[cfg(test)] +#[path = "corner_g2_tests.rs"] +mod tests; + +/// The G2 corner through the fillet path: a box's three edges at one corner rounded, rolling ball +/// against G2 patch, measured on the built bodies with the continuity analysis. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod fillet_corner_tests { + use super::*; + use crate::{ContinuityTolerances, SeamReport, measure, seam_diagnostics}; + + /// A 20 mm box with its three edges at one corner rounded by 4: the rolling ball fills that + /// corner with a piece of its sphere, the G2 route with a patch on the blends' own sections. + fn cornered(g2: bool) -> crate::Body { + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + if g2 { crate::fillet_g2_corners(&b, &edges, 4.0).unwrap() } else { crate::fillet(&b, &edges, 4.0).unwrap() } + } + + #[test] + fn the_ball_corner_is_measurably_g1_and_a_g2_corner_is_refused_until_it_sews() { + // The rolling ball: tangent to every blend, keeping its own 1/r across each corner seam. + let body = cornered(false); + let volume = measure(&body).unwrap().volume; + assert!(volume > 7000.0 && volume < 8000.0, "the box is still a solid: {volume}"); + let report = seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap(); + // Every seam of a filleted box is tangent; the corner's three are the short ones (the + // blend-to-face seams run the edge's own 12 mm and more). + let tangent: Vec<&SeamReport> = report.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + let corner: Vec<&&SeamReport> = tangent.iter().filter(|s| s.length < 8.0).collect(); + assert_eq!( + corner.len(), + 3, + "three corner seams among {} tangent ones: {:?}", + tangent.len(), + tangent.iter().map(|s| s.length).collect::>() + ); + let worst = corner.iter().map(|s| s.curvature_jump_per_mm.value).fold(0.0f64, f64::max); + eprintln!("rolling ball: corner seams jump {worst:.6} per mm (volume {volume:.3})"); + assert!((worst - 0.25).abs() < 1e-3, "the ball keeps its own 1/r (r = 4) across the seam: {worst}"); + + // The G2 corner patch is built on the blends' own sections, but sewing it into the shell + // is not done yet: the fillet with `corner: g2` refuses with a clear message rather than + // leaving the shell open, and leaves the ball the corner. + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + let err = crate::fillet_g2_corners(&b, &edges, 4.0).unwrap_err().to_string(); + eprintln!("G2 corner: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + } +} diff --git a/crates/kernel/src/corner_g2_tests.rs b/crates/kernel/src/corner_g2_tests.rs new file mode 100644 index 00000000..0968522b --- /dev/null +++ b/crates/kernel/src/corner_g2_tests.rs @@ -0,0 +1,154 @@ +//! What the G2 corner patch has to prove on a corner whose answer is known analytically: the box. + +use super::*; +use std::f64::consts::FRAC_PI_2; + +use crate::body::v3; + +/// One seam of a box corner: a quarter circle about `centre` from direction `u` to direction `w`, +/// with `leave` the way the patch leaves the blend's surface. A fillet is flat along its own axis, +/// so its curvature across the seam is nothing. +fn seam(centre: Vec3, u: Vec3, w: Vec3, leave: Vec3, r: f64, n: usize) -> Vec { + (0..n) + .map(|i| { + let theta = FRAC_PI_2 * i as f64 / (n - 1) as f64; + Frame { p: centre + (u * theta.cos() + w * theta.sin()) * r, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// A box corner at the origin (material x, y, z ≥ 0) with three fillets of radius `r`: the three +/// section curves where the fillets stop, meeting at (0,r,r), (r,0,r) and (r,r,0), and the middle of +/// the ball's octant a rolling ball would leave there. +fn box_corner(r: f64, n: usize) -> (Vec>, Vec3, Vec3) { + let c = Vec3::new(r, r, r); + let (a, b, d) = (Vec3::new(0.0, r, r), Vec3::new(r, 0.0, r), Vec3::new(r, r, 0.0)); + let dir = |p: Vec3| (p - c).normalized().unwrap_or(Vec3::X); + let sides = vec![ + // A → B on the z edge's cylinder (flat along z), B → C on the x edge's, C → A on the y + // edge's; each patch leaves the blend towards the material the corner caps. + seam(c, dir(a), dir(b), Vec3::new(0.0, 0.0, -1.0), r, n), + seam(c, dir(b), dir(d), Vec3::new(-1.0, 0.0, 0.0), r, n), + seam(c, dir(d), dir(a), Vec3::new(0.0, -1.0, 0.0), r, n), + ]; + let apex = c - Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z) * r; + (sides, apex, c) +} + +/// The rolling-ball corner the kernel builds today: the sphere through the same three seams, made +/// the way `curvecorner` makes it (a meridian arc swept about the axis through the ball's centre). +fn rolling_ball(r: f64, c: Vec3) -> mt::Surface { + let axis = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z); + let side = axis.any_perp(); + let meridian = builder::circle_arc(&builder::vertex(p3(c + axis * r)), &builder::vertex(p3(c - axis * r)), p3(c + side * r)); + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(c), v3(axis)))) +} + +/// The stations clear of the corners. Two blends meet at a corner with their seams 90° apart and +/// ask for different curvature at that one point, which no single patch can hold, and the corner's +/// own spline rounds the seam there; both effects fade within a few stations, so the measurement +/// starts clear of them. +fn interior(sides: &[Vec]) -> Vec> { + sides.iter().map(|s| s.iter().skip(4).take(s.len().saturating_sub(8)).copied().collect()).collect() +} + +#[test] +fn a_box_corner_is_curvature_continuous_with_every_blend() { + let r = 2.0; + let (sides, apex, centre) = box_corner(r, 25); + let faces = corner_g2_patch(&sides, apex).unwrap(); + let face = faces.first().expect("a corner patch"); + let surface = face.oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, 1.0 / r).unwrap(); + println!("G2 corner: gap {gap:e}, normal angle {angle:e} rad, curvature {curvature:e} (relative to 1/r)"); + assert!(gap <= r * 1e-5, "the patch is {gap} from the seam"); + // The stated tolerances: the normal angle within 0.01° and the curvature across the seam within + // 1% of the blend's own (here the fillet is flat across the seam, so that is 1% of 1/r). + assert!(angle < 0.01f64.to_radians(), "normal angle {angle} rad"); + assert!(curvature < 1e-2, "curvature across the seam is {curvature} of the blend's"); + + // The same measurement on the rolling-ball corner the kernel builds today: G1 (angle nought) + // but the ball's own 1/r across each seam, which is the whole of the blend's curvature. + let ball = rolling_ball(r, centre); + let (ball_gap, ball_angle, ball_curvature) = seam_error(&ball, &inner, 1.0 / r).unwrap(); + println!("rolling ball: gap {ball_gap:e}, normal angle {ball_angle:e} rad, curvature {ball_curvature:e}"); + assert!(ball_angle < 0.01f64.to_radians(), "the ball is tangent to each blend"); + assert!(ball_curvature > 0.5, "the ball keeps its own curvature across the seam: {ball_curvature}"); + assert!(curvature * 20.0 < ball_curvature, "the G2 patch has to be far closer than the ball"); +} + +#[test] +fn a_corner_meets_three_blends_of_different_shape_apart() { + // Blends that differ along their seams: a fillet of radius 2 next to rounder walls, each asking + // for its own curvature across the seam, at its own direction. + let r = 2.0; + let (mut sides, apex, _) = box_corner(r, 33); + let bends = [0.25, 0.4, 0.6]; + for (i, bend) in bends.iter().enumerate() { + let Some(side) = sides.get_mut(i) else { continue }; + for f in side.iter_mut() { + // Across the seam, perpendicular to both the leave direction and the seam. + f.bend = f.leave.any_perp() * *bend; + } + } + let faces = corner_g2_patch(&sides, apex).unwrap(); + let surface = faces.first().expect("a corner patch").oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, bends.iter().copied().fold(0.0, f64::max)).unwrap(); + println!("three different blends: gap {gap:e}, angle {angle:e} rad, curvature {curvature:e}"); + assert!(gap <= r * 1e-6, "{gap}"); + assert!(angle < 0.01f64.to_radians(), "{angle}"); + assert!(curvature < 1e-2, "each blend's own curvature across the seam: {curvature}"); +} + +#[test] +fn a_g2_corner_refuses_what_it_cannot_hold() { + let r = 2.0; + let (sides, apex, _) = box_corner(r, 9); + assert!(corner_g2_patch(&sides, apex).is_ok()); + + // Two blends (the ball's patch still fills that corner), or one. + assert!(corner_g2_patch(&sides[..2], apex).is_err()); + assert!(corner_g2_patch(&sides[..1], apex).is_err()); + assert!(corner_g2_patch(&[], apex).is_err()); + + // Too few stations to interpolate a cubic through, and too many to work with. + let thin: Vec> = sides.iter().map(|s| s[..3].to_vec()).collect(); + assert!(corner_g2_patch(&thin, apex).is_err()); + let fat: Vec> = sides.iter().map(|s| s[..1].iter().cycle().take(MAX_STATIONS + 1).copied().collect()).collect(); + assert!(corner_g2_patch(&fat, apex).is_err()); + + // A vertex the construction cannot hold: the section curves do not meet at their corners. + let mut apart = sides.clone(); + if let Some(first) = apart.first_mut().and_then(|s| s.first_mut()) { + first.p += Vec3::new(0.5, 0.0, 0.0); + } + assert!(corner_g2_patch(&apart, apex).is_err()); + + // A section curve that runs along the seam, so the patch has no direction to leave it in. + let along: Vec> = sides + .iter() + .map(|s| { + s.windows(2) + .map(|w| { + let d = w.first().zip(w.get(1)).map(|(a, b)| (b.p - a.p).normalized().unwrap_or(Vec3::X)).unwrap_or(Vec3::X); + Frame { leave: d, ..w.first().copied().unwrap_or(s[0]) } + }) + .chain(s.last().copied()) + .collect() + }) + .collect(); + assert!(corner_g2_patch(&along, apex).is_err()); + + // Hostile numbers never panic. + let mut nan = sides.clone(); + if let Some(f) = nan.first_mut().and_then(|s| s.get_mut(1)) { + f.p = Vec3::new(f64::NAN, 0.0, 0.0); + } + assert!(corner_g2_patch(&nan, apex).is_err()); + assert!(corner_g2_patch(&sides, Vec3::new(f64::INFINITY, 0.0, 0.0)).is_err()); + // A corner with no room: every station on the apex. + let on_apex: Vec> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect(); + assert!(corner_g2_patch(&on_apex, apex).is_err()); +} diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 6de2a465..d7234712 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1,10 +1,17 @@ //! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces -//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball of the -//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its -//! contacts the curves where the blend meets each side. The blend face is a rational B-spline -//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed -//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are -//! shortened to the contacts. +//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball rolls along +//! the edge touching both sides; its centre traces the spine and its contacts the curves where +//! the blend meets each side. The blend face is a rational B-spline through the circular +//! cross-sections (exact arcs at the samples), the side faces are trimmed back to the contact +//! curves, and seams that met the edge (lines, or arcs of an end face) are shortened to the +//! contacts. +//! +//! The ball's size along the chain is one of [`Size`]: one radius all along (the ordinary +//! fillet), a chord width across the blend (each section spans the requested width from contact +//! to contact, so its radius is `chord / (2 sin(ψ/2))` for the `ψ` between the two outward +//! normals where the ball touches), or a radius law along the chain (a [`RadiusSpline`] whose +//! positions are the fraction of the chain's arc length, zero only at an end, where the blend +//! closes to the edge's end point as the straight-edge fillet does). use std::collections::HashMap; @@ -12,6 +19,7 @@ use solidcraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::blend::too_large; +use crate::blend_variable::RadiusSpline; use crate::body::{Solid, from_p3, p3}; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -40,21 +48,60 @@ const TANGENT_RETRIES: usize = 8; const MAX_STEP_STRETCH: f64 = 3.0; /// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last. const KNIFE_STEPS: usize = 40; +/// Corrections of the radius a chord fillet makes to the width it measures across the blend. +const CHORD_STEPS: usize = 12; +/// A section this close to a point is closed: the blend ends at the edge's end point (a radius +/// that has run to zero there). +const CLOSED: f64 = 1e-7; +/// Parameters an edge is sampled at to measure the arc length a radius law is read off. +const LENGTH_STEPS: usize = 64; + +/// The ball's size along the edge chain. +#[derive(Clone, Copy)] +enum Size<'a> { + /// One radius all along. + Constant(f64), + /// The width across the blend, from contact to contact, at every station. + Chord(f64), + /// The radius at the station's arc-length fraction along the chain, 0 at the front and 1 at + /// the back of its edge. + Law(&'a RadiusSpline), +} struct Fit { surface: mt::Surface, contacts: [mt::Curve; 2], /// Parameter range belonging to this edge (the fit includes neighbouring sections). range: (f64, f64), + /// The part of the fit that is the chain's own run, as fractions of its parameter range. + span: (f64, f64), } struct Guide { sections: Vec
, + /// The size law's parameter (the arc-length fraction along the chain) at each section: the + /// radius between the samples is read off the law there, not off the spine's own length. + law: Vec, lengths: Vec, + /// The part of the guide that is the chain itself, as fractions of its length: the sections + /// run on past the ends of the chain (only so that the fit can be cut back there) and the fit + /// is checked for accuracy on the chain alone. + span: (f64, f64), } impl Guide { + /// A guide whose law parameter is the section's own fraction of it (the size is one radius, + /// which needs no law). fn new(sections: Vec
) -> Result { + let den = sections.len().max(2) - 1; + let law = (0..sections.len()).map(|i| i as f64 / den as f64).collect(); + Self::with_law(sections, law) + } + + fn with_law(sections: Vec
, law: Vec) -> Result { + if sections.len() != law.len() { + return Err(unsupported("degenerate rolling-ball spine")); + } let mut lengths = vec![0.0]; let mut total = 0.0; for pair in sections.windows(2) { @@ -65,10 +112,16 @@ impl Guide { if !total.is_finite() || total <= 1e-12 { return Err(unsupported("degenerate rolling-ball spine")); } - Ok(Self { sections, lengths }) + Ok(Self { sections, law, lengths, span: (0.0, 1.0) }) } + /// The centre and tangent at `along` of the spine. fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> { + self.anchor_at(along).map(|(centre, tangent, _)| (centre, tangent)) + } + + /// [`Guide::anchor`] with the size law's parameter at that station. + fn anchor_at(&self, along: f64) -> Option<(Vec3, Vec3, f64)> { if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) { return None; } @@ -81,7 +134,8 @@ impl Guide { return None; } let fraction = (distance - lo) / span; - Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?)) + let law = self.law.get(hi - 1)? * (1.0 - fraction) + self.law.get(hi)? * fraction; + Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?, law)) } } @@ -145,7 +199,7 @@ fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: boo }; let contacts = [contact(0)?, contact(2)?]; let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?; - Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range }) + Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range, span: (0.0, 1.0) }) } /// Compare the fitted strip with a solved circular section between interpolation nodes. @@ -263,7 +317,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2], // Both contacts are cut there: strictly inside the strip's own part. let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6; if !(inside(*t1) && inside(*t2)) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 309)")); } return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs))); } @@ -341,10 +395,10 @@ fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Ed }; let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o)); let v_t = rt.ok_or_else(fail)?; - let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?; + let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces (site 387)"))?; let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6; if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 390)")); } // How far the strip's point (u, v) is outside the end face (negative: inside the body). let outside = |u: f64, v: f64| -> Option { @@ -599,6 +653,93 @@ fn solve_from( None } +/// The section whose two contacts are `chord` apart: the ball's radius is `chord / (2 sin(ψ/2))` +/// for the `ψ` between the two outward normals where it touches, `|q1 − q2| = 2 r sin(ψ/2)`. The +/// normals turn as the ball grows against curved faces, so the width is measured on the solved +/// section and the radius corrected until it matches (the width is proportional to the radius, +/// so the corrections converge quickly). The normals at `p` give the first radius, exact where +/// both faces are planar; `None` where the faces meet too nearly tangentially to span a chord. +#[allow(clippy::too_many_arguments)] +fn solve_chord( + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + chord: f64, + sigma: f64, + cusp: f64, + extra: Option>, + tol: f64, +) -> Option<(Section, f64)> { + let normal = |fs: &[&mt::Face]| fs.iter().filter_map(|f| project(f, p, None)).min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|x| x.1); + let (n1, n2) = (normal(f1)?, normal(f2)?); + let half = (n1.dot(n2).clamp(-1.0, 1.0).acos() / 2.0).sin(); + if !(half > 1e-3) { + return None; + } + let mut r = chord / (2.0 * half); + for _ in 0..CHORD_STEPS { + if !(r.is_finite() && r > 0.0 && r < 1e9) { + return None; + } + let s = solve(f1, f2, p, t, r, sigma, cusp, extra, tol)?; + let width = s.q1.dist(s.q2); + if !width.is_finite() || width <= 0.0 { + return None; + } + if (width - chord).abs() <= (chord * 1e-9).max(tol * 0.5) { + return Some((s, r)); + } + r *= chord / width; + } + None +} + +/// The rolling ball at one station, sized by the blend's law. +struct Sizer<'a> { + size: Size<'a>, + /// The tolerance the section's equations are solved at. + tol: f64, +} + +impl Sizer<'_> { + /// The section at `p` (tangent `t`, the size law's parameter `s`) holding the third + /// condition `extra` when given, with the radius it was solved at. + #[allow(clippy::too_many_arguments)] + fn section( + &self, + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + s: f64, + sigma: f64, + cusp: f64, + extra: Option>, + ) -> Option<(Section, f64)> { + match self.size { + Size::Constant(r) => solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)), + Size::Chord(chord) => solve_chord(f1, f2, p, t, chord, sigma, cusp, extra, self.tol), + Size::Law(law) => { + let r = law_beyond(law, s)?; + if !(r.is_finite() && (0.0..1e6).contains(&r)) { + return None; + } + solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)) + } + } + } + + /// What a station the ball does not fit at is refused with: a chord too large is about the + /// size asked for, anything else about where the ball is. + fn refusal(&self) -> KernelError { + match self.size { + Size::Chord(_) => too_large("the blend is larger than the neighbouring faces (site 704)"), + _ => unsupported("the ball doesn't fit along the edge"), + } + } +} + /// Whether the strip's curvature jumps where two runs of sections meet (each three sections from /// the joint outward, the joint's first): for the centres or either contact, the difference of /// the circles through them would move the middle section by more than a tenth of `tolerance`. @@ -614,6 +755,12 @@ fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool { .any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1) } +/// Whether the section is a point: a blend whose radius has run to zero, closing to the edge's +/// end point there. +fn closed_section(s: &Section) -> bool { + s.c.dist(s.q1) <= CLOSED && s.c.dist(s.q2) <= CLOSED +} + /// Homogeneous control points of the circular (or straight) cross-section from q1 to q2. fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w); @@ -621,6 +768,11 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let m = (s.q1 + s.q2) * 0.5; return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]); } + if closed_section(s) { + // A closed section: the arc has shrunk to the edge's end point, all three controls + // there, so the fitted strips of the neighbouring faces meet at that vertex. + return Some([h(s.c, 1.0), h(s.c, 1.0), h(s.c, 1.0)]); + } let (a, b) = (s.q1 - s.c, s.q2 - s.c); let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos(); if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) { @@ -833,14 +985,14 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option .unwrap_or(end); let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?; if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 938)")); } Ok(t) }; let (ta, tb) = (at(&a, lo)?, at(&b, hi)?); let margin = (hi - lo) * 1e-9; if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 945)")); } if tb < hi { let _ = curve.cut(tb); @@ -851,16 +1003,100 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}"))) } +/// A monotone curve's parameter read off a table of its values at uniform parameters (`None` for +/// an empty table): the fraction of the way through it. +fn sampled(table: &[f64], along: f64) -> Option { + if table.len() < 2 || !along.is_finite() { + return None; + } + let x = along.clamp(0.0, 1.0) * (table.len() - 1) as f64; + let i = (x.floor() as usize).min(table.len() - 2); + let f = x - i as f64; + Some(table.get(i)? * (1.0 - f) + table.get(i + 1)? * f) +} + +/// The arc length of `edge` from its front to its back at each of `LENGTH_STEPS + 1` uniform +/// parameters, accumulated. `None` for an edge that has no length (or runs back over itself). +fn edge_lengths(edge: &mt::Edge) -> Option> { + use mt::{BoundedCurve, ParametricCurve}; + let c = edge.oriented_curve(); + let (a, b) = c.range_tuple(); + let mut last = from_p3(c.subs(a)); + let mut total = 0.0; + let mut out = Vec::with_capacity(LENGTH_STEPS + 1); + out.push(0.0); + for i in 1..=LENGTH_STEPS { + let p = from_p3(c.subs(a + (b - a) * i as f64 / LENGTH_STEPS as f64)); + total += last.dist(p); + last = p; + out.push(total); + } + (total.is_finite() && total > 1e-12).then_some(out) +} + +/// The radius a law gives at the chain fraction `s`, continued past 0 and 1 at the law's own end +/// slope. The ball runs on past a chain end (to be cut back there by its end face), and holding +/// the end radius would kink the strip exactly where the edge begins: a kink a cubic fit spreads +/// back over the whole blend. Never below a fifth of the end radius, so that a law running down +/// steeply still has a ball to run on with. +fn law_beyond(law: &RadiusSpline, s: f64) -> Option { + if (0.0..=1.0).contains(&s) { + return Some(law.eval(s)); + } + let (end, step) = if s < 0.0 { (0.0, s) } else { (1.0, s - 1.0) }; + let h = 1e-3; + let slope = (law.eval(end + h) - law.eval(end)) / h; + let r = law.eval(end); + let r = (r + slope * step).max(0.2 * r); + (r.is_finite() && r > 0.0).then_some(r) +} + /// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from /// `solid` (each blend face in place of its chain edge). pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) } +/// [`curve_blend`] with the blend `chord` wide across, from contact to contact, at every station. +pub(crate) fn curve_blend_chord(solid: &Solid, ids: &[mt::EdgeID], size: f64, chord: f64, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Chord(chord), round, MAX_SAMPLES) +} + +/// [`curve_blend`] with the radius following `law` along the chain (its positions the fraction of +/// the chain's arc length, from the front of its first edge to the back of its last). +pub(crate) fn curve_blend_law(solid: &Solid, ids: &[mt::EdgeID], size: f64, law: &RadiusSpline, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Law(law), round, MAX_SAMPLES) +} + fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { - if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { + curve_blend_sized(solid, ids, size, Size::Constant(r), round, max_samples) +} + +/// Blend the chain of edges (ids in any order) sized by `size`: one radius, a chord width, or a +/// radius law. With what it built from `solid` (each blend face in place of its chain edge). +fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Size<'_>, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { + if !(size.is_finite() && size > 0.0) { return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); } + match size_law { + Size::Constant(r) | Size::Chord(r) => { + if !(r.is_finite() && r > 1e-6 && r < 1e6) { + return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); + } + } + Size::Law(law) => { + let knots = law.knots.len() == law.radii.len() + law.degree + 1; + if law.radii.len() < 2 || !knots || law.radii.iter().any(|r| !(r.is_finite() && (0.0..1e6).contains(r))) { + return Err(KernelError::Invalid("a radius law along the edge must be finite and non-negative".into())); + } + // A law that runs to zero closes the blend at the edge's end point. The fitted strip + // pinches to a point there, and the sections' own interpolation cannot follow the last + // span of it: the straight-edge fillet, which builds that closure exactly, is the way. + if law.eval(0.0) <= CLOSED || law.eval(1.0) <= CLOSED { + return Err(unsupported("a radius law that runs to zero along a curved edge")); + } + } + } if !(4..=MAX_SAMPLES).contains(&max_samples) { return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into())); } @@ -991,10 +1227,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // below the first (against its outward normal) when the cusp is material, above when it is // a gap. The cross product of the normals, which tells the other edges apart, is zero here. let knife = 1.0 + n1.dot(n2) < KNIFE; + // The one radius, where the size is one radius (a chord or a law has none). + let fixed = match size_law { + Size::Constant(r) => Some(r), + _ => None, + }; + // The blend's scale where the size itself is not what matters (the knife edge's probe): the + // radius asked for, or the law's middle value. + let scale = match size_law { + Size::Constant(r) | Size::Chord(r) => r, + Size::Law(law) => law.eval(0.5).max(1e-6), + }; let (convex, cusp) = if knife { let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?; // Second order: from the point of face 1 a little way along `w`, face 2's nearest point. - let probe = project(f1, pm + w * r, None) + let probe = project(f1, pm + w * scale, None) .and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na))) .filter(|depth| depth.abs() > size * 1e-9) .ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?; @@ -1008,10 +1255,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if n1.dot(n2) > 1.0 - 1e-6 { return Err(unsupported("blending a smooth edge")); } + // The chain's arc length, in fractions of its whole, at each station: where the size is one + // radius it is not read, but the sections take their parameter from it all the same. + let tables: Vec> = chain.iter().map(|e| edge_lengths(e).unwrap_or_else(|| vec![0.0; LENGTH_STEPS + 1])).collect(); + let total: f64 = tables.iter().filter_map(|t| t.last().copied()).sum(); + if !matches!(size_law, Size::Constant(_)) && !(total.is_finite() && total > 1e-12) { + return Err(unsupported("a zero-length edge of the chain")); + } + let total = if total > 1e-12 { total } else { 1.0 }; + let mut cum: Vec = Vec::with_capacity(n + 1); + let mut acc = 0.0; + for table in &tables { + cum.push(acc); + acc += table.last().copied().unwrap_or(0.0); + } + cum.push(acc); + // A law that runs around a closed chain meets itself: its two ends must agree there. + if closed + && let Size::Law(law) = size_law + && (law.eval(0.0) - law.eval(1.0)).abs() > 1e-9 * law.radii.iter().copied().fold(1.0_f64, f64::max) + { + return Err(KernelError::Invalid("a closed edge chain runs back into itself: give the same radius law at both ends".into())); + } + // The law's parameter at `along` of edge `k` (its front to its back). + let frac_at = |k: usize, along: f64| -> f64 { + let (Some(table), Some(lo)) = (tables.get(k), cum.get(k)) else { return if k >= n { 1.0 } else { 0.0 } }; + let s = sampled(table, along).unwrap_or(0.0); + ((lo + s) / total).clamp(0.0, 1.0) + }; + let sizer = Sizer { size: size_law, tol: tol * 100.0 }; // Arcs on a planar side that curve round the ball: its centre runs round them at their // radius less the blend's. Less than nothing is too large; nothing is a pivot. let mut pivots: Vec> = vec![None; n]; - for (k, e) in chain.iter().enumerate() { + 'pivots: for (k, e) in chain.iter().enumerate() { + // A size that varies along the edge has no one radius to compare a corner's arc with. + let Some(r) = fixed else { break 'pivots }; let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue }; for side in [1, 2] { let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue }; @@ -1032,7 +1310,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } if rho < r - tol * 1e3 { - return Err(too_large("the blend is larger than a corner radius")); + return Err(too_large("the blend is larger than a corner radius (site 1257)")); } if (rho - r).abs() > tol * 1e3 { continue; @@ -1145,6 +1423,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?)) }; let mut vsec: Vec
= Vec::with_capacity(nv); + // The radius each vertex section was solved at (the closed end of a law: zero). + let mut vradius: Vec = Vec::with_capacity(nv); for k in 0..nv { let (p, t) = if k < n { tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0) @@ -1167,8 +1447,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let q = pv.c + (p - pv.o); vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } }); + vradius.push(fixed.unwrap_or_default()); continue; } + // The law's parameter at this vertex: the front of its edge, or the back of the last + // edge at an open chain's end. + let law_s = if k < n { frac_at(k, 0.0) } else { frac_at(n - 1, 1.0) }; if at_end { // A plane square to the edge holds the blend's end section. Any other end face: the // blend runs on past the end and is cut back where it meets that face. @@ -1181,7 +1465,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } _ => false, }; - if !square || split_ends.iter().any(|e| e.0 == k) { + // A radius that has run to zero closes the blend at the vertex itself: it does not run + // on past the end, and the end face keeps the corner it had there. + let closed_end = matches!(size_law, Size::Law(law) if law.eval(law_s) <= CLOSED); + if (!square || split_ends.iter().any(|e| e.0 == k)) && !closed_end { slanted.push((k, fi, p, t, None)); } } @@ -1212,7 +1499,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // longer is (a wall past the round at a corner, which a ball rolling round it pokes // through). let (a1, a2) = edge_sides(&[k.min(n - 1)]); - let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?; + let (s, r) = sizer.section(&a1, &a2, p, t, law_s, sigma, cusp, extra).ok_or_else(|| sizer.refusal())?; // A seam on the other side too must pass through that side's contact. if let Some((side, seam)) = here.get(1).filter(|_| !at_end) { let q = if *side == 1 { s.q1 } else { s.q2 }; @@ -1222,10 +1509,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } vsec.push(s); + vradius.push(r); } // Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near // at the chain's start, near to far at its end), far enough to cross the end face. - let mut beyond: Vec<(usize, Vec
)> = Vec::new(); + let mut beyond: Vec<(usize, Vec
, Vec)> = Vec::new(); for (k, fi, p, t, tangent) in slanted.iter_mut() { let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?; let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?; @@ -1250,12 +1538,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if cos < 0.15 && tangent.is_none() { return Err(unsupported("an end face almost along the edge")); } - let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r; + let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + vradius.get(*k).copied().unwrap_or(scale); let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos }; let out = if *k == 0 { -*t } else { *t }; + // The run on past the end only gives the fit its context (the end face cuts it back), but + // it keeps the law's end slope: the radius is read at the distance past the end. + let law_s = |d: f64| if *k == 0 { -d / total } else { 1.0 + d / total }; // Past a tangent end the straight run along the tangent leaves the curved spine: where // the ball no longer fits, the run is shortened (it only gives the fit its context). let mut list = Vec::with_capacity(EXTENSION); + let mut laws = Vec::with_capacity(EXTENSION); let mut reach = reach; // Past the end of a curved edge the planes square to its end tangent cut the ring of // centres further and further round it, until a step jumps across the ring (#1255: a @@ -1263,26 +1555,33 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // then, as long as one of its sections still lies wholly past the end face. let ng_out = if ng.dot(out) < 0.0 { -ng } else { ng }; let past = |sec: &Section| [sec.c, sec.q1, sec.q2].iter().all(|x| (*x - *p).dot(ng_out) > tol); - let mut fallback: Option> = None; + // A run shortened when its centres jump across the ring (a knuckle's cap circle, #1255) + // stands if no full-length run works; a size law's own end slope is read at each station's + // distance past the end. + let mut fallback: Option> = None; for attempt in 0..TANGENT_RETRIES { list.clear(); + laws.clear(); let step = reach / EXTENSION as f64; let sections = (1..=EXTENSION).map(|j| { - let anchor = *p + out * (step * j as f64); - solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0) + let d = step * j as f64; + let anchor = *p + out * d; + let s = law_s(d); + sizer.section(&e1, &e2, anchor, *t, s, sigma, cusp, None).map(|(section, _)| (s, section)) }); match sections.collect::>>() { Some(all) => { let smooth = tangent.is_some() - || std::iter::once(at.c).chain(all.iter().map(|s| s.c)).collect::>().windows(2).all(|w| match w { + || std::iter::once(at.c).chain(all.iter().map(|(_, s)| s.c)).collect::>().windows(2).all(|w| match w { [a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step, _ => true, }); if smooth { // A shortened run must still cross the end face; else the first, // full-length run stands. - if fallback.is_none() || all.iter().any(past) { - list = all; + if fallback.is_none() || all.iter().any(|(_, s)| past(s)) { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); } break; } @@ -1294,12 +1593,27 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, reach *= if tangent.is_some() { 0.5 } else { 0.75 }; } if list.is_empty() { - list = fallback.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?; + match fallback { + // The shortened run stands when the ring's step jumped, whatever its size. + Some(all) => { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); + } + // A size asked for beyond what the faces around the end hold is refused as such; + // one radius past the end is the rolling ball's own failure to fit. + None => { + return Err(match size_law { + Size::Constant(_) => unsupported("the ball doesn't fit past the end of the edge"), + _ => too_large("the blend is larger than the neighbouring faces (site 1472)"), + }); + } + } } if *k == 0 { list.reverse(); + laws.reverse(); } - beyond.push((*k, list)); + beyond.push((*k, list, laws)); } // Uniform refinement preserves a common spacing across neighbouring patches. Work is // capped: a difficult strip returns an error instead of accepting an inaccurate fit. @@ -1319,12 +1633,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1))) }) .collect::>()?; - let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result
{ + let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<(Section, f64)> { let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?; let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?; let anchor = p + t * (start * (1.0 - along) + end * along); let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) + sizer.section(&a1, &a2, anchor, t, frac_at(k, along), sigma, cusp, None).ok_or_else(|| sizer.refusal()) }; let mut samples = SAMPLES.min(max_samples); let (esec, fits) = loop { @@ -1339,18 +1653,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; + let mut law = vec![frac_at(k, 0.0)]; + let (mut front_beyond, mut back_beyond) = (0usize, 0usize); for j in 1..samples - 1 { - list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?); + let along = j as f64 / (samples - 1) as f64; + let (section, _) = section_at(k, e, along)?; + list.push(section); + law.push(frac_at(k, along)); } list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); - for (kv, ext) in &beyond { + law.push(frac_at(k, 1.0)); + for (kv, ext, exts) in &beyond { if *kv == k && k == 0 { list.splice(0..0, ext.iter().copied()); + law.splice(0..0, exts.iter().copied()); + front_beyond = ext.len(); } else if *kv == k + 1 && k + 1 == nv - 1 { list.extend(ext.iter().copied()); + law.extend(exts.iter().copied()); + back_beyond = ext.len(); } } - guides.push(Some(Guide::new(list)?)); + let mut guide = Guide::with_law(list, law)?; + // The chain's own run: the spliced sections at either end are context for the fit + // only (the blend is cut back where it meets the end face there). + let last = guide.lengths.last().copied().unwrap_or(0.0); + let at = |i: usize| if last > 0.0 { guide.lengths.get(i).copied().unwrap_or(0.0) / last } else { 0.0 }; + guide.span = (at(front_beyond), at(guide.sections.len().saturating_sub(1 + back_beyond))); + guides.push(Some(guide)); } // Intersection curves inherit their leader's piecewise parameter speed. Fitting // those parameters creates oscillations even on a geometrically smooth spine. @@ -1364,9 +1694,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if along == 1.0 { return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into())); } - let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; + let (anchor, tangent, law_s) = guide.anchor_at(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0) + sizer + .section(&a1, &a2, anchor, tangent, law_s, sigma, cusp, None) + .map(|(section, _)| section) .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) }; let esec: Vec> = (0..n) @@ -1432,6 +1764,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, (Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?, _ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?, }; + let mut fit = fit; + fit.span = guides.get(k).and_then(Option::as_ref).map(|g| g.span).unwrap_or((0.0, 1.0)); fits.push(Some(fit)); } let mut accurate = true; @@ -1440,6 +1774,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, for j in 0..samples - 1 { for fraction in [0.25, 0.5, 0.75] { let along = (j as f64 + fraction) / (samples - 1) as f64; + // A size that varies along the edge reaches past its ends only for the fit's + // context (the end face cuts that away), so it is not held to the tolerance + // there. One radius keeps the check it always had, the whole strip. + if !matches!(size_law, Size::Constant(_)) + && fits.get(k).and_then(Option::as_ref).is_some_and(|f| along < f.span.0 || along > f.span.1) + { + continue; + } let section = uniform_section(k, along)?; if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, along, round, sigma, fit_tolerance)))? { accurate = false; @@ -1515,7 +1857,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, && n == 1 && !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1668)")); } crate::guard("curve blend", || { let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); @@ -1530,6 +1872,18 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, *b = builder::vertex(curve.subs(hi)); } } + // A closed section (a radius that has run to zero) has one vertex for both contacts, so + // the blend face's wire and the side faces' meet there. + for (k, s) in vsec.iter().enumerate() { + if !closed_section(s) { + continue; + } + if let Some(v) = v1.get(k).cloned() + && let Some(slot) = v2.get_mut(k) + { + *slot = v; + } + } // Along a pivot the planar side's contact is one point: one vertex at both its ends. for (k, pv) in pivots.iter().enumerate() { let Some(pv) = pv else { continue }; @@ -1557,18 +1911,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } } - let mut arcs = Vec::with_capacity(nv); + // The section's own radius, where the blend is sized by a law or a chord width (`r` is the + // one radius of a constant blend, exactly as before). + let rad = |s: &Section| match size_law { + Size::Constant(r) => r, + Size::Chord(_) | Size::Law(_) => s.c.dist(s.q1), + }; + let mut arcs: Vec> = Vec::with_capacity(nv); let mut split_arcs: HashMap> = HashMap::new(); for (k, s) in vsec.iter().enumerate() { let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); - let e = if round { - let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r; - builder::circle_arc(a, b, p3(m)) + // A closed section has no arc to run between the contacts: the blend meets itself + // there, at the edge's end point. + let e = if closed_section(s) { + None + } else if round { + let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * rad(s); + Some(builder::circle_arc(a, b, p3(m))) } else { - builder::line(a, b) + Some(builder::line(a, b)) }; let e = match caps.iter().find(|c| c.0 == k) { - Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?, + Some((_, _, _, _, curve)) => Some(mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?), None => e, }; if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) { @@ -1581,7 +1945,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result { use mt::{ParametricSurface3D, SearchNearestParameter}; let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; - let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; + let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * rad(s); if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { let nn = surface.normal(u, v); let want = (s.c - mid) * sigma; @@ -1688,7 +2052,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .filter(|f| f.edge_iter().any(|e| e.id() == id)) .any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol)); if !fits || !crate::blend::on_extension(&seam, q, on_tol) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1853)")); } } seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?); @@ -1708,7 +2072,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0)); let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0)); if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1873)")); } let a = a.unwrap_or_else(|| seam.front().clone()); let b = b.unwrap_or_else(|| seam.back().clone()); @@ -1723,7 +2087,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if corners.iter().any(|k| split_arcs.contains_key(k)) { let connectors: Vec = corners .iter() - .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect())) + .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().flatten().into_iter().collect())) .collect(); let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?; rec.face(&rebuilt, f); @@ -1754,8 +2118,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, es.push(ne.clone()); } if let Some(k) = arrives { - let arc = arcs.get(k).ok_or_else(|| fail("arc"))?; - let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); + // A closed section has no blend arc at the corner: the blend ends at + // the vertex itself and the end face keeps the corner it had. + let (Some(arc), Some(a1), Some(a2)) = (arcs.get(k).and_then(Option::as_ref), v1.get(k), v2.get(k)) else { + continue; + }; let to = if used_up { e.front() } else { ne.back() }; let corner = if to == a1 { arc.clone() @@ -1810,9 +2177,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } // Blend faces: each runs its side-1 contact the other way from side 1's face. for (k, bf) in blends.iter().enumerate() { - let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % nv)) else { + let (Some(e1), Some(e2)) = (c1.get(k), c2.get(k)) else { return Err(fail("blend")); }; + let ak = arcs.get(k).and_then(Option::as_ref); + let ak1 = arcs.get((k + 1) % nv).and_then(Option::as_ref); let f1i = side1.get(k).ok_or_else(|| fail("side"))?; let f1 = faces.get(*f1i).ok_or_else(|| fail("side"))?; let chain_e = chain.get(k).ok_or_else(|| fail("edge"))?; @@ -1824,20 +2193,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .map(|e| e.front() == chain_e.front()) .ok_or_else(|| fail("edge use"))?; // Side 1 runs the contact forward (as the chain) when it ran the edge forward. - // A pivot's planar contact is a point: three sides. - let edges = if used_fwd { - [e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] + // A pivot's planar contact is a point: three sides. A closed section has no arc: two. + let edges: Vec = if used_fwd { + [e1.as_ref().map(|e| e.inverse()), ak.cloned(), e2.clone(), ak1.map(|e| e.inverse())] } else { - [e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())] + [e1.clone(), ak1.cloned(), e2.as_ref().map(|e| e.inverse()), ak.map(|e| e.inverse())] } .into_iter() .flatten() - .collect::>(); + .collect(); + // An arc the chamfer crossed is two lines through the crossing: the blend meets the + // shortened seam there. let wire: mt::Wire = edges .into_iter() .flat_map(|e| { let split = [(k, ak), ((k + 1) % nv, ak1)] .into_iter() + .filter_map(|(i, arc)| arc.map(|arc| (i, arc.clone()))) .find(|(_, arc)| arc.id() == e.id()) .and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts))); match split { @@ -1861,8 +2233,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, #[path = "curvecorner.rs"] mod corner; +pub(crate) use corner::corner_blend_style; +// The planar corner builder the G2 corner work left in place of `corner_blend_style`'s sphere. +#[allow(unused_imports)] pub(crate) use corner::corner_blend; #[cfg(test)] #[path = "curveblend_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "curveblend_law_tests.rs"] +mod law_tests; diff --git a/crates/kernel/src/curveblend_law_tests.rs b/crates/kernel/src/curveblend_law_tests.rs new file mode 100644 index 00000000..f9d5195d --- /dev/null +++ b/crates/kernel/src/curveblend_law_tests.rs @@ -0,0 +1,315 @@ +//! Chord-width and radius-law blends along curved edges: the width across the blend at every +//! station, and the radius along the edge, measured on the body the kernel built against values +//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis). + +use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; +use solidcraft_geom::Vec3; + +use super::*; +use crate::blend::vtx; +use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline}; +use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export}; + +/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on +/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces +/// run from 63° to 117° apart around it. +fn oblique_boss() -> (Body, Vec3, Vec3) { + let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap(); + let base = Vec3::new(0.0, 0.0, -10.0); + let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap(); + let rod = cylinder(base, axis, 8.0, 30.0).unwrap(); + (boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis) +} + +/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a +/// cylinder away from the front (the wall curves into the part), otherwise the part is the box +/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base +/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it). +fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) { + let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap(); + let (axis, op, side) = + if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) }; + let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap(); + let body = boolean(&block, &tool, op).unwrap().unwrap(); + (body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side) +} + +/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces +/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves). +fn blend_face(input: &Body, body: &Body) -> Option { + use mt::ParametricSurface; + let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6)); + body.solid + .face_iter() + .filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_))) + .find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5)))) + .cloned() +} + +/// The edges of the blend face's boundary, each once, with the point at the middle of it (the +/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face). +fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> { + let Some(f) = blend_face(input, body) else { return Vec::new() }; + let mut out: Vec<(mt::Edge, Vec3)> = Vec::new(); + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + if out.iter().any(|(x, _)| x.id() == e.id()) { + continue; + } + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5)))); + } + } + out +} + +/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out. +fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec { + blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect() +} + +/// The fraction of the way along `edge` (its absolute front to its back) of the point of it +/// nearest `q`. +fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option { + let e = edge.absolute_clone(); + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = vec![from_p3(c.subs(lo))]; + let mut run = vec![0.0]; + for i in 1..=steps { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64)); + run.push(run.last().copied()? + at.last().copied()?.dist(p)); + at.push(p); + } + let total = *run.last()?; + let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY); + let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0); + let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?; + let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64); + let (k, f) = (i.floor() as usize, i.fract()); + let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None }; + Some((a + (b - a) * f) / total) +} + +/// The azimuth of `p` about the Z axis through `axis`. +fn azimuth(axis: Vec3, p: Vec3) -> f64 { + (p.y - axis.y).atan2(p.x - axis.x) +} + +/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`. +fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option { + let off = |p: Vec3| { + let mut d = azimuth(axis, p) - phi; + while d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + while d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } + d + }; + let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY); + for e in edges { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = lo; + let mut dist = f64::INFINITY; + for i in 0..=steps { + let t = lo + (hi - lo) * i as f64 / steps as f64; + let d = off(from_p3(c.subs(t))).abs(); + if d < dist { + (at, dist) = (t, d); + } + } + let step = (hi - lo) / steps as f64; + let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi)); + let g = 0.618_033_988_749_894_9; + for _ in 0..80 { + let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0)); + if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() { + x1 = b; + } else { + x0 = a; + } + } + let t = 0.5 * (x0 + x1); + let d = off(from_p3(c.subs(t))).abs(); + if d < best_d { + (best, best_d) = (from_p3(c.subs(t)), d); + } + } + (best_d < 1e-6).then_some(best) +} + +/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from +/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean +/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip +/// back there is only placed for one radius, so a width that varies along the edge is refused +/// rather than approximated (the fixed radius of `fillet` along the same edge rolls). +#[test] +fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() { + let (body, _, _) = oblique_boss(); + let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0); + // A fixed radius rolls along the same chain: the edge itself is supported. + assert!(fillet(&body, &[pick], 2.5).is_ok()); + let e = fillet_chord(&body, &[pick], 4.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); +} + +/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend +/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant +/// fillet of that radius) and its two contacts are that far apart at every station. +#[test] +fn chord_fillet_along_an_arc_edge() { + let (chord, r) = (3.0, 3.0 / 2f64.sqrt()); + let (body, mid, axis, _) = arc_walled_box(false); + let blended = fillet_chord(&body, &[mid], chord).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let plain = fillet(&body, &[mid], r).unwrap(); + let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume); + assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}"); + // The contact on the plane and the one on the wall at the same azimuth: the width across. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6); + assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's"); + let mid_azimuth = azimuth(axis, mid); + let mut worst: f64 = 0.0f64; + let mut stations = 0; + for i in 1..12 { + let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9; + let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue }; + stations += 1; + worst = worst.max((p.dist(w) - chord).abs()); + } + eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations"); + assert!(stations >= 9, "only {stations} stations measured"); + assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}"); +} + +/// A chord fillet on a straight edge between planar faces is still the exact local round: the +/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is +/// r = c / √2). +#[test] +fn chord_fillet_on_a_straight_edge_is_unchanged() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0); + let f = fillet_chord(&b, &[at], chord).unwrap(); + let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap(); + assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap()); + assert_eq!(f.unmeshed_faces(), 0); +} + +/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction +/// of the edge's length rolls along it, and the setback on the planar face — the plane and the +/// wall meet at a right angle, so it is the section's radius — follows the law from end to end. +#[test] +fn variable_radius_along_an_arc_edge_follows_the_law() { + let (body, mid, axis, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + // Started from the edge's back end: the law runs the other way, as the positions are from the + // end nearest `start`. + let start = vtx(edge.absolute_back()); + // A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip: + // the sections' own last spans cannot be held to the fit's accuracy there. That is refused, + // not approximated. + for wide in [4.0, 5.0] { + let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err(); + assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}"); + } + let (r0, r1) = (2.0, 3.4); + let blended = fillet_variable(&body, mid, start, r0, r1).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false); + // The law runs from the end nearest `start`: the chain's front here is the far end. + let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() }; + // `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1 + // at the front. + assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12); + // The blend's contact on the bottom plane is at 40 − r from the axis. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + assert!(!plane.is_empty(), "no contact on the bottom plane"); + let mut worst: f64 = 0.0; + let mut stations = 0; + let mut shown = Vec::new(); + for e in &plane { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + for i in 0..=32 { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0)); + let measured = 40.0 - (p - axis).len(); + let along = along_fraction(&edge, p).unwrap(); + // Leave the two ends, where the caps cut the contact: the blend closes on the end + // faces there. + if !(0.04..=0.96).contains(&along) { + continue; + } + stations += 1; + if shown.len() < 3 || (0.45..0.55).contains(&along) { + shown.push((along, measured, want.eval(along))); + } + worst = worst.max((measured - want.eval(along)).abs()); + } + } + eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}"); + assert!(stations >= 24, "only {stations} stations measured"); + assert!(worst < 0.02, "the section radius is {worst} mm off the law"); + // Wider, still inside what the strip holds: the same body as the exact straight-edge case of + // that law is not built here, but the radius is the law's at every station measured. +} + +/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would +/// close to the edge's end point, but the fitted strip pinches to a point there and the last span +/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is +/// the way (see `variable_radius_down_to_zero_at_one_end`). +#[test] +fn a_law_running_to_zero_along_a_curved_edge_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] { + let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err(); + assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}"); + let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] }; + assert!(fillet_variable_spline(&body, mid, mid, &law).is_err()); + // A law that stays clear of zero along it rolls: the blend is closed, a three-sided face + // where nothing closes it. + let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap(); + assert!(made.validity().is_empty(), "{:?}", made.validity()); + let mesh = made.tessellate_with(5e-4, true).unwrap(); + let about = |o: Vec3| { + mesh.triangles + .iter() + .map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0) + .sum::() + }; + assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed"); + } +} + +/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size, +/// never a silent approximation and never a panic. +#[test] +fn a_chord_or_radius_the_faces_cannot_hold_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + // A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block). + let e = fillet_chord(&body, &[mid], 30.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok()); + // A radius law too large for the wall. + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err()); + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err()); + // Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched. + let bad = |radii: Vec| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii }; + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err()); + assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err()); + // An edge the body does not have is refused by name, not approximated. + assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err()); +} diff --git a/crates/kernel/src/curvecorner.rs b/crates/kernel/src/curvecorner.rs index 46b943d0..affabdaa 100644 --- a/crates/kernel/src/curvecorner.rs +++ b/crates/kernel/src/curvecorner.rs @@ -25,6 +25,7 @@ use super::{ }; use crate::blend::{too_large, vtx}; use crate::body::{Solid, from_p3, p3, v3}; +use crate::bridge::Frame; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -683,9 +684,65 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec)], Err(fail("a boundary does not close")) } +/// The strip's own station nearest the corner ball `c`: the section where the strip stops against +/// that ball, where its direction of leave and its curvature are read. +fn corner_station(strip: &Strip, c: Vec3) -> Option { + strip.sections.iter().min_by(|a, b| a.1.c.dist(c).total_cmp(&b.1.c.dist(c))).map(|x| x.0) +} + +/// One side of a G2 corner: the corner ball's own section arc of radius `r` about `c`, from its +/// contact `q_from` to its contact `q_to`, each station carrying the direction the corner patch +/// leaves the blend in (towards the corner's apex) and the blend's own curvature across it, read +/// from `strip` where the ball stops against it. A rolling ball's section is a circular arc about +/// its own centre wherever the edge runs, so this arc is the blend's section curve itself, and its +/// ends are the ball's contacts exactly — the sides close the loop whatever the fit's station +/// spacing is. +fn g2_side(strip: &Strip, c: Vec3, r: f64, q_from: Vec3, q_to: Vec3, apex: Vec3) -> Result> { + use mt::ParametricSurface; + /// Stations across one section: enough for the patch's cubic cross-sections, few enough to + /// keep the corner's own patch small. + const STATIONS: usize = 9; + let vec_of = |d: mt::Vector3| Vec3::new(d.x, d.y, d.z); + let v = corner_station(strip, c).ok_or_else(|| fail("corner section"))?; + let a = (q_from - c).normalized().ok_or_else(|| fail("corner contact"))?; + let b = (q_to - c).normalized().ok_or_else(|| fail("corner contact"))?; + let dot = a.dot(b).clamp(-1.0, 1.0); + let w = (b - a * dot).normalized().ok_or_else(|| fail("corner contact"))?; + let theta = dot.acos(); + // The blend's own direction of leave and curvature across the seam, at its corner station. + let speed = vec_of(strip.surface.vder(0.5, v)).len(); + if !(speed > 1e-30) { + return Err(unsupported("a corner section of no width")); + } + let leave = vec_of(strip.surface.vder(0.5, v)) * (1.0 / speed); + let bend = vec_of(strip.surface.vvder(0.5, v)) * (1.0 / (speed * speed)); + if !(leave.is_finite() && bend.is_finite() && theta.is_finite() && r.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + let mut out = Vec::with_capacity(STATIONS); + for k in 0..STATIONS { + let t = theta * k as f64 / (STATIONS - 1) as f64; + let p = c + (a * t.cos() + w * t.sin()) * r; + // The patch leaves the blend towards the material the corner caps, so the sign is the one + // towards the corner's apex (the blends' own v direction runs away from the vertex). + let leave = if leave.dot(apex - p) < 0.0 { -leave } else { leave }; + if !(p.is_finite() && leave.is_finite() && bend.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + out.push(Frame { p, leave, bend, bend_change: Vec3::ZERO }); + } + Ok(out) +} + /// Blend the edges `ids` of `solid` with radius `r` (a chamfer's distance), rebuilding the /// corners at their ends; with what it built from `solid`. pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { + corner_blend_style(solid, ids, size, r, round, false) +} + +/// [`corner_blend`]; with `g2`, the corners where three blends meet are G2 patches +/// ([`crate::corner_g2`]) instead of pieces of the rolling ball's sphere. +pub(crate) fn corner_blend_style(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, g2: bool) -> Result<(Solid, Rebuild)> { if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { return Err(KernelError::Invalid("blend size and radius must be finite and positive".into())); } @@ -1006,6 +1063,33 @@ pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z); let side = out.any_perp(); let (np, sp, back) = (c + side * r, c - side * r, c - out * r); + if g2 { + // A G2 patch on the blends' own end sections, gathered at the ball's pole + // (the point the sphere's meridians are built through above). Each side is + // the strip's section through the ball's contact with the face it shares + // with the previous strip; the strips run on past their edges, so that + // station is found by projecting the contact point, not taken from the + // edge's own end. + let s2 = strip_of(e2)?; + let s3 = strip_of(e3)?; + // The cap's own pole: the ball's point nearest the corner (the sphere + // patch's `back` above is its opposite pole, where the meridian starts). + let apex = c + out * r; + // Side 1 runs from the ball's contact with the face it shares with e3 + // round to the one it shares with e2, and so on round the loop: each + // side's last station is the next side's first, at that contact. + let sides = [g2_side(s1, c, r, q31, q12, apex)?, g2_side(s2, c, r, q12, q23, apex)?, g2_side(s3, c, r, q23, q31, apex)?]; + let _patch = crate::corner_g2::corner_g2_patch(&sides, apex)?; + // Sewing the patch into the shell is not done yet: it brings its own + // boundary vertices where the strips' contacts end at the ball's contacts + // (`v12`/`v23`/`v31`), and a truck vertex is its allocation, not its point, + // so the rebuilt shell does not close there (rewrapping the section curves + // onto the contacts is the missing step). Refused rather than left open: + // the rolling ball fills the corner instead. + return Err(unsupported( + "a G2 corner sewn between these blends (the patch's section curves do not join the blends' contacts yet)", + )); + } let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( meridian.oriented_curve(), diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 92c076a0..94acc8c1 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -442,7 +442,7 @@ fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: add(p)?; } } - Seg2::Bezier { n, p } if (1..=7).contains(&n) => { + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { for p in p.iter().take(usize::from(n) + 1) { add(*p)?; } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 22756f87..daf37d7b 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -28,7 +28,9 @@ mod boundary; mod bridge; mod build; mod clearance; +mod continuity; mod coplanar; +mod corner_g2; mod curveblend; mod delete_face; #[cfg(test)] @@ -67,6 +69,7 @@ mod issue_871_tests; #[cfg(test)] mod join_between_caps_tests; mod lip; +pub mod loft_blend; mod loopblend; mod matchsurf; mod measure; @@ -123,7 +126,7 @@ mod tspline; mod untouched_faces_tests; pub mod watch; -pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_max, fillet_max_each}; +pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each}; pub use blend_corner::fillet_radii; pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; @@ -134,6 +137,10 @@ pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, sweep_with, torus, }; +pub use continuity::{ + ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, +}; +pub use corner_g2::{corner_g2_patch, seam_error}; pub use delete_face::delete_faces; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use fair::{ @@ -145,6 +152,7 @@ pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; pub use lip::raise_band; +pub use loft_blend::loft_blend; pub use matchsurf::{match_surface, seam_continuity}; pub use measure::{BodyMeasure, measure}; pub use mesh_convert::convert_prismatic_mesh; diff --git a/crates/kernel/src/loft_blend.rs b/crates/kernel/src/loft_blend.rs new file mode 100644 index 00000000..2364b200 --- /dev/null +++ b/crates/kernel/src/loft_blend.rs @@ -0,0 +1,550 @@ +//! Multi-section blends: one face that runs through many cross-sections and still leaves the +//! first and last of them the way a support surface wants. +//! +//! The sections run in order along the face's **u**, each sampled at the same `samples` stations +//! along the face's **v**. Section `i` sits at `u = i/(n−1)`, and the face passes through it +//! exactly: its v-curve there is that section's own cross-section curve through its stations. +//! Along v the stations are interpolated by a B-spline of degree `min(3, samples − 1)` — the same +//! interpolation [`crate::bridge`] builds its boundary curves with — so every station of every +//! section lies on the face. +//! +//! Along u the face is a B-spline whose knots are clamped at both ends and repeated at every +//! interior section, so every span is one Bézier; a span's leading control rows are the Hermite +//! rows of `bridge::section` — position, tangent (`leave`), curvature (`bend`) and change of +//! curvature (`bend_change`), scaled to that span's parameter interval. The degree is the largest +//! `c + c′ − 1` over the spans (`c` conditions at the section the span leaves, `c′` at the one it +//! arrives at), and a span whose two ends carry fewer conditions than that is elevated exactly, +//! which moves no point of the curve. A blend of two sections is therefore the [`crate::bridge`] +//! of those two boundaries, only with the two parameters the other way round. +//! +//! **The first and last section** take their tangent, curvature and change of curvature from the +//! caller's frames, exactly as a bridge takes them from a support surface's boundary, and hold +//! them to the requested [`Continuity`] (G0 up to G3). The `leave` direction of the last section's +//! frames points back into the blend, towards the interior, as a bridge's far boundary does. +//! +//! **Interior sections** take nothing from the caller's frames but their positions: the run +//! direction's tangent there is the secant of the two neighbouring sections' stations, its +//! curvature their second difference and its change of curvature their third difference, all +//! along the run. The two spans that meet at such a section are given exactly those rows, so they +//! agree in ∂/∂u, ∂²/∂u² and ∂³/∂u³ there and the joint is **C³** in the run parameter (hence G³) +//! whenever a fourth station is there to take the third difference over — that is, whenever the +//! blend runs through at least four sections. A blend of exactly three sections has only its one +//! interior section's two neighbours to read, so no third difference can be taken: the interior +//! section then keeps position, tangent and curvature only and its joint is **C²** (G²). Dropping +//! interior conditions, never the surface, is also how this blend stays within +//! [`MAX_SPLINE_DEGREE`]: a section family asks for at most `c + c′ − 1 ≤ 7` at the four conditions +//! a section can carry today, and a caller that ever asked for more would lose interior +//! conditions (change of curvature first, then curvature, then tangent) rather than fail. +//! +//! `crown` scales how fast the face leaves the first and the last section (0 the plain blend, up +//! to 3 fuller, down to −0.9 flatter), exactly as a bridge's crown does. It shapes the middle of +//! each end span and leaves every joint's conditions alone. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{CurveN, HPoint, MAX_SPLINE_DEGREE, NurbsSurface}; +use truck_modeling as mt; +use truck_modeling::{ParametricSurface3D, SearchNearestParameter}; + +use crate::body::{Body, from_p3, p3}; +use crate::bridge::{Continuity, Frame}; +use crate::surfaces::surface_body; +use crate::{KernelError, Result, guard}; + +/// Most cross-sections one blend may run through. +pub const MAX_BLEND_SECTIONS: usize = 50; +/// Fewest stations one cross-section may be sampled at (a straight line across it). +pub const MIN_BLEND_STATIONS: usize = 2; +/// Most stations one cross-section may be sampled at. The control net (and the interpolation that +/// builds it) grows with the station count, and no cross-section curve needs more than this. +pub const MAX_BLEND_STATIONS: usize = 257; + +/// How many rows of the control net one interpolation solve carries at a time: a row is three +/// coordinates, so this many rows are solved together. +const ROWS_PER_SOLVE: usize = 16; + +/// A blend surface through `sections`, in order, that leaves the first and the last of them with +/// the continuity their frames ask for. +/// +/// Each section is one cross-section: `samples` stations (`samples` must match every section's +/// length, so that the stations correspond from one section to the next). Section `i` is +/// interpolated at `u = i/(n−1)`, and its stations run along `v`, so the face of the body holds +/// every station of every section. `orders[0]` and `orders[1]` are what the face holds at the +/// first and the last section, read from their frames; the interior sections join the face +/// smoothly by finite differences along the run (see the module comment for the order each joint +/// holds). `crown` scales how fast the face leaves the first (`crown[0]`) and the last +/// (`crown[1]`) section and must be within −0.9 and 3. +/// +/// Refused, without ever panicking: fewer than 2 or more than [`MAX_BLEND_SECTIONS`] sections, +/// sections of unequal station counts or a `samples` below [`MIN_BLEND_STATIONS`] or above +/// [`MAX_BLEND_STATIONS`], a frame that is not finite or a `leave` with no direction, a crown +/// outside its range, and two neighbouring sections that meet at a station (no room for a blend). +pub fn loft_blend(sections: &[Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result { + let blend = Blend::new(sections, orders, crown, samples)?; + let net = blend.net()?; + guard("loft blend surface", || crate::spline_conv::tensor_face(&net).map(|f| surface_body(vec![f]))) +} + +/// The frames of one cross-section picked as an edge chain of a body, sampled at `stations` +/// stations: the chain itself, with the neighbouring face's tangent plane, curvature vector and +/// change of curvature read along `leave` — the direction the blend runs on from this section — +/// exactly as [`crate::bridge::frames`] reads a support surface's own boundary. +/// +/// Hand the result to [`loft_blend`] as one section. The face the section repeats is the body's +/// face that holds the whole chain and faces the run best; `leave` must have a part in that face's +/// tangent plane (a direction normal to it has no tangent plane to leave in). The last section's +/// `leave` runs back into the blend, towards the section before it. +pub fn section_frames(body: &Body, edges: &[Vec3], leave: Vec3, stations: usize) -> Result> { + guard("loft blend section", || { + let chain = crate::bridge::chain(body, edges)?; + let leave = + leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?; + let first = chain.first().ok_or_else(|| KernelError::Failed("loft blend: no chain".into()))?; + let mid = (from_p3(first.front().point()) + from_p3(first.back().point())) * 0.5; + // The face the chain lies on that the blend leaves through: the one whose tangent plane + // holds as much of `leave` as possible. + let mut best: Option<(f64, mt::Face)> = None; + for face in body.solid.face_iter() { + if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) { + continue; + } + let surf = face.surface(); + let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue }; + let normal = surf.normal(u, v); + let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO); + let score = (leave - n * leave.dot(n)).len(); + if best.as_ref().is_none_or(|(b, _)| score > *b) { + best = Some((score, face.clone())); + } + } + let (score, face) = best.ok_or_else(|| KernelError::Invalid("no face of the body holds the picked edges".into()))?; + if score < 0.2 { + return Err(KernelError::Invalid("the direction towards the next section lies in no face at the picked edges".into())); + } + crate::bridge::frames(&face, &chain, leave, stations) + }) +} + +/// What the face has to do at one section: where it is, and the run direction's first, second and +/// third derivatives at each of the section's stations. The third is carried only when the +/// section holds a change-of-curvature condition. +struct Shape { + p: Vec, + d1: Vec, + d2: Vec, + d3: Option>, +} + +/// The blend's inputs, validated, with the conditions and the run direction's degree worked out. +struct Blend<'a> { + sections: &'a [Vec], + crown: [f64; 2], + samples: usize, + run: usize, + /// The run parameter interval between two neighbouring sections: `1/(run − 1)`. + step: f64, + degree: usize, + /// Conditions held at each section, one per control row of the spans that end there. + conditions: Vec, +} + +impl<'a> Blend<'a> { + fn new(sections: &'a [Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result> { + if !(MIN_BLEND_STATIONS..=MAX_BLEND_STATIONS).contains(&samples) { + return Err(KernelError::Invalid(format!( + "a blend needs {MIN_BLEND_STATIONS} to {MAX_BLEND_STATIONS} stations per cross-section, not {samples}" + ))); + } + let run = sections.len(); + if !(2..=MAX_BLEND_SECTIONS).contains(&run) { + return Err(KernelError::Invalid(format!("a blend runs through 2 to {MAX_BLEND_SECTIONS} cross-sections, not {run}"))); + } + for c in crown { + if !c.is_finite() || !(-0.9..=3.0).contains(&c) { + return Err(KernelError::Invalid("a blend's crown must be between -0.9 and 3".into())); + } + } + for (i, section) in sections.iter().enumerate() { + if section.len() != samples { + return Err(KernelError::Invalid(format!( + "every cross-section of a blend must be sampled at the same {samples} stations (section {i} has {})", + section.len() + ))); + } + for (s, f) in section.iter().enumerate() { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) { + return Err(KernelError::Invalid(format!("the frame at section {i}, station {s} is not finite"))); + } + if f.leave.normalized().is_none() { + return Err(KernelError::Invalid(format!("the leave direction at section {i}, station {s} is zero"))); + } + } + } + // The face runs from one section to the next: where two of them meet at a station there is + // no room for a blend between them. + for i in 0..run - 1 { + for s in 0..samples { + if !(point(sections, i, s)?.dist(point(sections, i + 1, s)?) > 1e-9) { + return Err(KernelError::Invalid(format!("sections {} and {} meet at station {s}: there is no room for a blend", i + 1, i + 2))); + } + } + } + // The ends hold what their orders ask for. An interior section would like all four + // conditions (position, tangent, curvature, change of curvature); where no fourth station + // is there to take the third difference over it keeps the three its neighbours can give, + // and its joint is C² instead of C³. + let mut conditions = vec![2usize; run]; + if let Some(slot) = conditions.first_mut() { + *slot = orders[0].conditions(); + } + if let Some(slot) = conditions.last_mut() { + *slot = orders[1].conditions(); + } + for i in 1..run.saturating_sub(1) { + if let Some(slot) = conditions.get_mut(i) { + *slot = if third_difference_available(i, run) { 4 } else { 3 }; + } + } + // The run direction's degree is the largest `c + c′ − 1` of a span. Four conditions a + // section is at most what a section can carry, so this fits today; should a caller ever + // ask for more, the interior joints give up conditions rather than the blend failing. + let mut degree = span_degree(&conditions); + while degree > MAX_SPLINE_DEGREE { + let next = (1..run.saturating_sub(1)) + .filter(|&i| conditions.get(i).is_some_and(|&c| c > 1)) + .max_by_key(|&i| conditions.get(i).copied().unwrap_or(0)); + let Some(i) = next else { break }; + if let Some(c) = conditions.get_mut(i) { + *c = c.saturating_sub(1); + } + degree = span_degree(&conditions); + } + if degree == 0 || degree > MAX_SPLINE_DEGREE { + return Err(KernelError::Failed("loft blend: the requested continuity needs a degree above the kernel's".into())); + } + Ok(Blend { sections, crown, samples, run, step: 1.0 / (run - 1) as f64, degree, conditions }) + } + + /// What the face has to do at section `i`: the run direction's derivatives at every station. + fn shape(&self, i: usize) -> Result { + let samples = self.samples; + let p: Vec = (0..samples).map(|s| point(self.sections, i, s)).collect::>>()?; + let conditions = self.conditions.get(i).copied().unwrap_or(1); + let mut d1 = vec![Vec3::ZERO; samples]; + let mut d2 = vec![Vec3::ZERO; samples]; + let mut d3 = (conditions >= 4).then(|| vec![Vec3::ZERO; samples]); + if i == 0 { + for s in 0..samples { + let f = frame(self.sections, 0, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + // The speed a bridge gives a boundary: a third of the station's chord to the + // section it leaves for, the crown, over this span's parameter interval. + let speed = f.p.dist(point(self.sections, 1, s)?) / 3.0 * (1.0 + self.crown[0]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = leave * speed; + *v2 = f.bend * (speed * speed); + } + } + } else if i + 1 == self.run { + for s in 0..samples { + let f = frame(self.sections, i, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + let speed = f.p.dist(point(self.sections, i - 1, s)?) / 3.0 * (1.0 + self.crown[1]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + // The run leaves the last section outwards; the caller's frames there point + // back into it, so the odd derivatives turn round. + *v1 = leave * -speed; + *v2 = f.bend * (speed * speed); + } + } + } else { + let h = self.step; + for s in 0..samples { + let before = point(self.sections, i - 1, s)?; + let here = point(self.sections, i, s)?; + let after = point(self.sections, i + 1, s)?; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = (after - before) * (1.0 / (2.0 * h)); + *v2 = (after - here * 2.0 + before) * (1.0 / (h * h)); + } + } + } + // The change of curvature: the caller's bend_change at an end, a third difference of the + // neighbouring sections at an interior one. + if let Some(third) = d3.as_mut() { + for s in 0..samples { + let (Some(&a), Some(&b)) = (d1.get(s), d2.get(s)) else { + return Err(KernelError::Failed("loft blend: a section's derivatives".into())); + }; + let value = if i == 0 { + let f = frame(self.sections, 0, s)?; + third_derivative(a, b, f.bend_change)? + } else if i + 1 == self.run { + let f = frame(self.sections, i, s)?; + -third_derivative(-a, b, f.bend_change)? + } else { + self.third_difference(i, s)? + }; + if let Some(slot) = third.get_mut(s) { + *slot = value; + } + } + } + Ok(Shape { p, d1, d2, d3 }) + } + + /// The third difference of the sections' stations at an interior section: the estimate of how + /// the curvature vector changes along the run. `i` is an interior section, and the neighbours + /// reach far enough for a stencil (see [`third_difference_available`]). + fn third_difference(&self, i: usize, s: usize) -> Result { + let h = self.step; + let cube = h * h * h; + let before = i.checked_sub(2); + let value = match (before, i + 2 < self.run) { + // Both sides: the symmetric five-point difference. + (Some(k), true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 2.0 + point(self.sections, i - 1, s)? * 2.0 + - point(self.sections, k, s)?) + * (1.0 / (2.0 * cube)) + } + // Only ahead: the forward four-point stencil at the second of them. + (None, true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i - 1, s)?) + * (1.0 / cube) + } + // Only behind: the same the other way round. + (Some(k), false) => { + (point(self.sections, k, s)? - point(self.sections, i - 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i + 1, s)?) + * (1.0 / cube) + } + (None, false) => return Err(KernelError::Failed("loft blend: no fourth station for a change of curvature".into())), + }; + if !value.is_finite() { + return Err(KernelError::Failed("loft blend: the sections' third difference is not finite".into())); + } + Ok(value) + } + + /// The Bézier rows of the span from section `j` to section `j + 1`: one control row per + /// condition at each end, elevated to the run direction's degree so the whole face is one + /// tensor surface. Row `k` of the span is control row `j·degree + k` of the face. + fn span(&self, j: usize, left: &Shape, right: &Shape) -> Result>> { + let (Some(&kl), Some(&kr)) = (self.conditions.get(j), self.conditions.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section with no conditions".into())); + }; + let m = kl.saturating_add(kr).saturating_sub(1); + if !(1..=MAX_SPLINE_DEGREE).contains(&m) { + return Err(KernelError::Failed("loft blend: a span's degree".into())); + } + let k = m as f64; + let delta = self.step; + let one = delta / k; + let two = if m >= 2 { delta * delta / (k * (k - 1.0)) } else { 0.0 }; + let three = if m >= 3 { delta * delta * delta / (k * (k - 1.0) * (k - 2.0)) } else { 0.0 }; + let mut out = vec![vec![Vec3::ZERO; self.samples]; m + 1]; + let missing = || KernelError::Failed("loft blend: a section's derivatives".into()); + for s in 0..self.samples { + // The near end: one row per condition, position first. + let p = *left.p.get(s).ok_or_else(missing)?; + put(&mut out, 0, s, p)?; + if kl >= 2 { + let d1 = *left.d1.get(s).ok_or_else(missing)?; + let q1 = p + d1 * one; + put(&mut out, 1, s, q1)?; + if kl >= 3 { + let d2 = *left.d2.get(s).ok_or_else(missing)?; + let q2 = d2 * two + q1 * 2.0 - p; + put(&mut out, 2, s, q2)?; + if kl >= 4 { + let d3 = left.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, 3, s, d3 * three + q2 * 3.0 - q1 * 3.0 + p)?; + } + } + } + // The far end, its own conditions read backwards. + let e = *right.p.get(s).ok_or_else(missing)?; + put(&mut out, m, s, e)?; + if kr >= 2 { + let d1 = *right.d1.get(s).ok_or_else(missing)?; + let r1 = e - d1 * one; + put(&mut out, m - 1, s, r1)?; + if kr >= 3 { + let d2 = *right.d2.get(s).ok_or_else(missing)?; + let r2 = d2 * two + r1 * 2.0 - e; + put(&mut out, m - 2, s, r2)?; + if kr >= 4 { + let d3 = right.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, m - 3, s, r2 * 3.0 - r1 * 3.0 + e - d3 * three)?; + } + } + } + } + // A span whose two ends carry fewer conditions than the run direction's degree is + // elevated: the same curve, written in more control rows. + while out.len().saturating_sub(1) < self.degree { + out = elevate(&out); + } + Ok(out) + } + + /// The run direction's knots: clamped at both ends, repeated `degree` times at every interior + /// section — so the face interpolates each section and every span in between is one Bézier. + fn run_knots(&self) -> Vec { + let degree = self.degree; + let mut knots = vec![0.0; degree + 1]; + for i in 1..self.run.saturating_sub(1) { + knots.extend(std::iter::repeat_n(i as f64 / (self.run - 1) as f64, degree)); + } + knots.extend(std::iter::repeat_n(1.0, degree + 1)); + knots + } + + /// The face's control net: u runs with the sections, v along each section's curve. + fn net(&self) -> Result { + let shapes: Vec = (0..self.run).map(|i| self.shape(i)).collect::>>()?; + // One span per pair of neighbouring sections. The first row of a span is the last row of + // the one before it — the shared section — so only the first span writes it. + let mut rows: Vec> = Vec::with_capacity((self.run - 1) * self.degree + 1); + for j in 0..self.run.saturating_sub(1) { + let (Some(left), Some(right)) = (shapes.get(j), shapes.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section's shape".into())); + }; + for row in self.span(j, left, right)?.into_iter().skip(usize::from(j > 0)) { + rows.push(row); + } + } + let count_u = rows.len(); + // Along each section's curve the rows are the interpolation through that section's + // stations: the same parameters and degree for every row, so every row has one knot + // vector. Rows are solved in batches of `ROWS_PER_SOLVE` (three coordinates each), which + // shares one elimination between them. + let params: Vec = (0..self.samples).map(|s| s as f64 / (self.samples - 1) as f64).collect(); + let degree_v = 3.min(self.samples - 1); + let mut ctrl = vec![HPoint::ZERO; count_u * self.samples]; + let mut knots_v: Option> = None; + for (batch, chunk) in rows.chunks(ROWS_PER_SOLVE).enumerate() { + const WIDTH: usize = 3 * ROWS_PER_SOLVE; + let mut points: Vec<[f64; WIDTH]> = Vec::with_capacity(self.samples); + for s in 0..self.samples { + let mut q = [0.0f64; WIDTH]; + for (r, row) in chunk.iter().enumerate() { + if let Some(p) = row.get(s) { + for (slot, value) in q.iter_mut().skip(3 * r).zip([p.x, p.y, p.z]) { + *slot = value; + } + } + } + points.push(q); + } + let curve = CurveN::::interpolate(¶ms, &points, degree_v).map_err(|e| KernelError::Failed(format!("loft blend: {e}")))?; + if knots_v.is_none() { + knots_v = Some(curve.knots.clone()); + } + for (s, c) in curve.ctrl.iter().enumerate() { + for (r, xyz) in c.chunks(3).enumerate() { + let i = batch * ROWS_PER_SOLVE + r; + // Coordinates past the net's last row are this batch's own padding: their + // controls are not part of the surface. + if i >= count_u { + break; + } + if let [x, y, z] = *xyz { + let Some(slot) = s.checked_mul(count_u).and_then(|base| base.checked_add(i)).and_then(|k| ctrl.get_mut(k)) else { + continue; + }; + *slot = HPoint::weighted(Vec3::new(x, y, z), 1.0); + } + } + } + } + let knots_v = knots_v.ok_or_else(|| KernelError::Failed("loft blend: no row could be interpolated".into()))?; + NurbsSurface::from_homogeneous([self.degree, degree_v], [self.run_knots(), knots_v], ctrl) + .map_err(|e| KernelError::Failed(format!("loft blend: {e}"))) + } +} + +/// The frame at section `i`, station `s`. +fn frame(sections: &[Vec], i: usize, s: usize) -> Result<&Frame> { + sections.get(i).and_then(|section| section.get(s)).ok_or_else(|| KernelError::Failed("loft blend: a section is missing a station".into())) +} + +/// The point of section `i` at station `s`. +fn point(sections: &[Vec], i: usize, s: usize) -> Result { + frame(sections, i, s).map(|f| f.p) +} + +/// `rows[k][s] = v`, an error when the net has no such row or station. +fn put(rows: &mut [Vec], k: usize, s: usize, v: Vec3) -> Result<()> { + match rows.get_mut(k).and_then(|row| row.get_mut(s)) { + Some(slot) => { + *slot = v; + Ok(()) + } + None => Err(KernelError::Failed("loft blend: the control net is too small".into())), + } +} + +/// One exact degree elevation of a Bézier control net of rows: the same curve, one control row +/// more. +fn elevate(rows: &[Vec]) -> Vec> { + let degree = rows.len().saturating_sub(1); + let up = degree as f64 + 1.0; + let mut out: Vec> = Vec::with_capacity(rows.len() + 1); + if let Some(first) = rows.first() { + out.push(first.clone()); + } + for i in 1..=degree { + if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) { + let a = i as f64 / up; + out.push(before.iter().zip(here).map(|(x, y)| *x * a + *y * (1.0 - a)).collect()); + } + } + if let Some(last) = rows.last() { + out.push(last.clone()); + } + out +} + +/// Whether a third difference for the interior section `i` can be taken from the sections around +/// it: a stencil needs a station on one side of it and two on the other, and with three sections +/// there is none. +fn third_difference_available(i: usize, run: usize) -> bool { + i >= 2 || i + 2 < run +} + +/// The degree a family of sections asks for: the largest `c + c′ − 1` over its spans. +fn span_degree(conditions: &[usize]) -> usize { + let mut degree = 0; + for w in conditions.windows(2) { + if let [a, b] = w { + degree = degree.max(a.saturating_add(*b).saturating_sub(1)); + } + } + degree +} + +/// The third derivative a curve needs at a boundary so that the arclength derivative of its +/// curvature vector is `target`, from its first two derivatives there. Only the normal-plane part +/// of the third derivative can hold that condition; the rest is a reparameterisation of the +/// cross-section. This is `bridge`'s own construction, kept here so the bridge core stays +/// untouched. +fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result { + let sigma = d1.len(); + if !(sigma > 1e-12) || !sigma.is_finite() { + return Err(KernelError::Failed("loft blend: no room for the curvature condition".into())); + } + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let r = tp * (-d2.dot(t)) - t * d2.dot(tp); + let x = (target * (sigma.powi(5)) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2))) * (1.0 / (sigma * sigma)); + if !x.is_finite() { + return Err(KernelError::Failed("loft blend: the change of curvature is not finite".into())); + } + Ok(x - t * x.dot(t)) +} + +#[cfg(test)] +#[path = "loft_blend_tests.rs"] +mod tests; diff --git a/crates/kernel/src/loft_blend_tests.rs b/crates/kernel/src/loft_blend_tests.rs new file mode 100644 index 00000000..df60b675 --- /dev/null +++ b/crates/kernel/src/loft_blend_tests.rs @@ -0,0 +1,460 @@ +//! Tests of the multi-section blend: analytic cross-sections given as frames, and the surface +//! measured back off the face it built (a grid of points, five-point differences along the run). + +use super::*; +use crate::body::from_p3; +use crate::bridge::{bridge, sample_grid}; +use truck_modeling as mt; +use truck_modeling::ParametricSurface; + +/// The blend's surface: it is one face. +fn surface_of(body: &Body) -> mt::Surface { + body.solid.face_iter().next().unwrap().surface() +} + +/// Position and the run direction's first three derivatives at `(t, v)` of the face, read with the +/// five-point differences `bridge_tests` reads a cross-section with. The samples run inwards from +/// the section (so nothing is asked of the face outside its own domain); `backwards` reads with +/// the parameter falling and turns the odd derivatives round, so every value is a derivative in +/// the run direction `u`. +fn run_derivs(body: &Body, t: f64, v: f64, backwards: bool, h: f64) -> (Vec3, Vec3, Vec3, Vec3) { + let face = body.solid.face_iter().next().unwrap(); + let surf = face.surface(); + let dir = if backwards { -1.0 } else { 1.0 }; + let q: Vec = (0..5).map(|k| from_p3(surf.subs(t + dir * k as f64 * h, v))).collect(); + let d1 = (q[0] * -25.0 + q[1] * 48.0 + q[2] * -36.0 + q[3] * 16.0 + q[4] * -3.0) * (dir / (12.0 * h)); + let d2 = (q[0] * 35.0 + q[1] * -104.0 + q[2] * 114.0 + q[3] * -56.0 + q[4] * 11.0) * (1.0 / (12.0 * h * h)); + let d3 = (q[0] * -5.0 + q[1] * 18.0 + q[2] * -24.0 + q[3] * 14.0 + q[4] * -3.0) * (dir / (2.0 * h * h * h)); + (q[0], d1, d2, d3) +} + +/// The geometric shape of a curve from its first three derivatives: the unit tangent, the +/// curvature vector and the arclength derivative of that curvature vector (`bridge`'s own +/// reading, kept local to the test). +fn curve_shape(d1: Vec3, d2: Vec3, d3: Vec3) -> (Vec3, Vec3, Vec3) { + let sigma = d1.len(); + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp); + let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2)); + (t, nn * (1.0 / (sigma * sigma)), kappa * (1.0 / sigma.powi(5))) +} + +/// The angle between two vectors, in degrees. +fn angle_deg(a: Vec3, b: Vec3) -> f64 { + (a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// A circle of radius `r` at height `z` about the run (the z axis), sampled at `n` stations. Each +/// frame leaves along `leave`; its `bend` is given per station as a function of the station's +/// outward radial direction (a support surface curving in the leave direction), and its change of +/// curvature is `change`. +fn circle_section(r: f64, z: f64, n: usize, leave: Vec3, bend: impl Fn(Vec3) -> Vec3, change: Vec3) -> Vec { + (0..n) + .map(|s| { + let a = std::f64::consts::TAU * s as f64 / n as f64; + let p = Vec3::new(r * a.cos(), r * a.sin(), z); + Frame { p, leave, bend: bend(Vec3::new(p.x / r, p.y / r, 0.0)), bend_change: change } + }) + .collect() +} + +/// `count` straight cross-sections along x, `10` apart in z and rising in order: the run is +z. +/// Every frame leaves along +z except the last one, which leaves back into the blend along −z as +/// a bridge's far boundary does. `bend` and `change` are the same at every station, and `bend` is +/// perpendicular to the run (the only shape a support surface can have along a direction it does +/// not bend around). +fn line_sections(count: usize, n: usize, bend: Vec3, change: Vec3) -> Vec> { + (0..count) + .map(|i| { + let leave = if i + 1 == count { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + (0..n).map(|s| Frame { p: Vec3::new(10.0 * s as f64 / (n - 1) as f64, 0.0, 10.0 * i as f64), leave, bend, bend_change: change }).collect() + }) + .collect() +} + +/// A point at arclength `s` along the perimeter of the rounded rectangle of half-width `a`, +/// half-height `b` and corner radius `r` (in the z = 0 plane, counter-clockwise from the right +/// edge's lower end). +fn rounded_rect_at(a: f64, b: f64, r: f64, s: f64) -> Vec3 { + let (sa, sb) = ((a - r).max(0.0), (b - r).max(0.0)); + let quarter = std::f64::consts::FRAC_PI_2; + let segs = [2.0 * sb, quarter * r, 2.0 * sa, quarter * r, 2.0 * sb, quarter * r, 2.0 * sa, quarter * r]; + let mut left = s; + let mut k = 0; + while k + 1 < segs.len() && left > segs[k] { + left -= segs[k]; + k += 1; + } + let f = if segs[k] > 0.0 { left / segs[k] } else { 0.0 }; + let arc = |centre: Vec3, from: f64| { + let ang = from + quarter * f; + Vec3::new(centre.x + r * ang.cos(), centre.y + r * ang.sin(), 0.0) + }; + match k { + 0 => Vec3::new(a, -sb + 2.0 * sb * f, 0.0), + 1 => arc(Vec3::new(sa, sb, 0.0), 0.0), + 2 => Vec3::new(sa - 2.0 * sa * f, b, 0.0), + 3 => arc(Vec3::new(-sa, sb, 0.0), quarter), + 4 => Vec3::new(-a, sb - 2.0 * sb * f, 0.0), + 5 => arc(Vec3::new(-sa, -sb, 0.0), std::f64::consts::PI), + 6 => Vec3::new(-sa + 2.0 * sa * f, -b, 0.0), + _ => arc(Vec3::new(sa, -sb, 0.0), 3.0 * quarter), + } +} + +/// A rounded rectangle at height `z`, sampled at `n` even arclength steps around it: a section +/// that is not a scaled copy of the circles it can sit between, with a plan support (no bend). +fn rounded_rect_section(a: f64, b: f64, r: f64, z: f64, n: usize) -> Vec { + let total = 4.0 * ((a - r).max(0.0) + (b - r).max(0.0)) + 2.0 * std::f64::consts::PI * r; + (0..n) + .map(|s| { + let p = rounded_rect_at(a, b, r, total * s as f64 / n as f64); + Frame { p: Vec3::new(p.x, p.y, z), leave: Vec3::new(0.0, 0.0, 1.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// Two parallel circular cylinders of radius `r` 20 apart in y, leaving towards each other along +/// ∓y, each bending up towards its own axis and with a curvature that does not change as it goes +/// round (`bridge_tests`'s own case, for the two-section comparison). +fn valley_between_cylinders(count: usize, r: f64) -> (Vec, Vec) { + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..count { + let y = 10.0 * i as f64 / (count - 1) as f64; + a.push(Frame { + p: Vec3::new(0.0, y, 0.0), + leave: Vec3::new(0.0, 1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0), + }); + b.push(Frame { + p: Vec3::new(0.0, y + 20.0, 0.0), + leave: Vec3::new(0.0, -1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0), + }); + } + (a, b) +} + +/// Three circles of different sizes at three heights, the middle one smaller: a shape that +/// changes as the face runs up it. +fn three_circles(n: usize) -> Vec> { + vec![ + // The first section is the equator of a sphere of its own radius: the support bends + // inwards by 1/r along the run, and its curvature does not change as it goes round. + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |radial| radial * -0.1, Vec3::new(0.0, 0.0, -0.01)), + // The middle section's own shape data are ignored: the blend reads it from its neighbours. + circle_section(8.0, 10.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + // The last section's frames leave back into the blend, along −z. + circle_section(12.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ] +} + +/// Every station of every section is on the face, within the tolerance a blend promises. +fn assert_runs_through(body: &Body, sections: &[Vec], n: usize) { + let grid = sample_grid(body, sections.len(), n).unwrap(); + for (k, section) in sections.iter().enumerate() { + for (s, f) in section.iter().enumerate() { + assert!(grid[k][s].dist(f.p) < 1e-9, "section {k} station {s}: {:?} vs {:?}", grid[k][s], f.p); + } + } +} + +#[test] +fn three_sections_run_through_every_station() { + let n = 9; + let sections = three_circles(n); + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The ends hold what their frames ask for: the leave direction is the tangent, and the + // support's own curvature (1/10 inwards on the sphere, nothing on the plane) is the face's. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + // The stations run round the circle at even angles. + let angle = std::f64::consts::TAU * s as f64 / n as f64; + let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); + let (_, d1, d2, d3) = run_derivs(&body, 0.0, v, false, 1e-3); + let (t, bend, _) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "the face leaves along +z: {t:?}"); + assert!(bend.dist(radial * -0.1) < 1e-4, "the face leaves the sphere with its curvature: {bend:?}"); + // The last section's frames leave back into the blend, so read the shape that way round. + let (_, d1, d2, d3) = run_derivs(&body, 1.0, v, true, 1e-3); + let (t, bend, _) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "and arrives along the far leave: {t:?}"); + assert!(bend.len() < 1e-3, "the last section's support is flat: {bend:?}"); + } + + // Three sections: the one interior joint has no fourth station to take a third difference + // over, so it holds position, tangent and curvature — C² — and both sides agree. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + let (from_left, left1, left2, left3) = run_derivs(&body, 0.5, v, false, 1e-4); + let (from_right, right1, right2, right3) = run_derivs(&body, 0.5, v, true, 1e-4); + assert!(from_left.dist(from_right) < 1e-9, "the joint is on the middle section"); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "the tangent is continuous: {left1:?} vs {right1:?}"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "the curvature is continuous: {left2:?} vs {right2:?}"); + // But the change of curvature is not: a three-point stencil carries no third difference, + // so the joint is C² and not C³, and the difference stays whatever the step size is. + assert!((left3 - right3).len() > 0.05 * (1.0 + left3.len()), "a C² joint is not C³: {left3:?} vs {right3:?}"); + // What it holds is the sections' own finite differences along the run: the secant of the + // two neighbours for the tangent, their second difference for the curvature. + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-7 * (1.0 + secant.len()), "the tangent is the neighbours' secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-4 * (1.0 + second.len()), "the curvature is the neighbours' second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn the_ends_hold_the_shape_their_frames_ask_for() { + let stations = 7; + let bend = Vec3::new(0.0, 0.2, 0.0); + let change = Vec3::new(0.0, 0.5, 0.0); + let sections = line_sections(3, stations, bend, change); + let z = |s: f64| s * 10.0; + + // G1 holds the tangent and nothing more. + let g1 = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g1, 0.0, v, false, 1e-3); + let (t, _, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G1 blend leaves along leave: {t:?}"); + } + + // G2 holds the tangent and the support's curvature, at both ends. + let g2 = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g2, 0.0, v, false, 1e-3); + let (t, k, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G2 blend leaves along leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G2 blend holds the support's curvature: {k:?} vs {bend:?}"); + let (p, d1, d2, d3) = run_derivs(&g2, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9, "the face ends on the last section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "reading back, it arrives along the far leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "the far end holds the curvature too: {k:?}"); + assert!(c.len() < 1e3); + } + // A G2 blend leaves the change of curvature free: moving the frames' own value does not move + // the face at the first section, while a G3 blend follows it. + let (_, d1, d2, d3) = run_derivs(&g2, 0.0, 0.5, false, 1e-3); + let free = curve_shape(d1, d2, d3).2; + let moved = line_sections(3, stations, bend, change + Vec3::new(0.0, 1.0, 0.0)); + let g2_moved = loft_blend(&moved, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g2_moved, 0.0, 0.5, false, 1e-3); + assert!(curve_shape(d1, d2, d3).2.dist(free) < 1e-9, "a G2 blend does not read the far frame's change"); + + // G3 holds the change of curvature as well: its normal part is what a surface match can hold, + // and here the frame's own change is normal (along its curvature vector). + let g3 = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (_, d1, d2, d3) = run_derivs(&g3, 0.0, v, false, 1e-3); + let (t, k, c) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G3 blend still holds the curvature: {k:?}"); + assert!((c.y - change.y).abs() < 1e-3, "a G3 blend holds the change of curvature: {c:?} vs {change:?}"); + let (p, d1, d2, d3) = run_derivs(&g3, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3); + assert!((c.y - change.y).abs() < 1e-3, "and the far end holds it too: {c:?} vs {change:?}"); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9); + } + // With G3, moving the frames' own value does move the face there. + let g3_moved = loft_blend(&moved, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g3_moved, 0.0, 0.5, false, 1e-3); + assert!((curve_shape(d1, d2, d3).2.y - free.y).abs() > 0.5, "a G3 blend follows the frame's change"); +} + +#[test] +fn four_sections_join_at_c3() { + let n = 9; + let sections: Vec> = [(10.0, 0.0), (9.0, 10.0), (8.0, 20.0), (11.0, 30.0)] + .iter() + .enumerate() + .map(|(i, &(r, z))| { + let leave = if i == 3 { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + circle_section(r, z, n, leave, |_| Vec3::ZERO, Vec3::ZERO) + }) + .collect(); + let body = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // Every joint now has a fourth station for the third difference, so the two sides agree in + // the tangent, the curvature and its change: the joints are C³. + let h = 1.0 / 3.0; + for (i, s) in [(1usize, 0usize), (1, 4), (1, 8), (2, 0), (2, 4), (2, 8)] { + let t = i as f64 / 3.0; + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, left3) = run_derivs(&body, t, v, true, 1e-4); + let (_, right1, right2, right3) = run_derivs(&body, t, v, false, 1e-4); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "joint {i} station {s}: the tangent agrees"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "joint {i} station {s}: the curvature agrees"); + // The change of curvature agrees too, as far as finite differences can tell: its + // mismatch falls away with the step size, so the joint is C³, not just C². + assert!( + (left3 - right3).len() < 2e-2 * (1.0 + left3.len()), + "joint {i} station {s}: the change of curvature agrees: {left3:?} vs {right3:?}" + ); + // What they hold are the sections' own differences along the run. + let p = |k: usize| sections[k][s].p; + let d1 = (p(i + 1) - p(i - 1)) * (1.0 / (2.0 * h)); + let d2 = (p(i + 1) - p(i) * 2.0 + p(i - 1)) * (1.0 / (h * h)); + let d3 = if i == 1 { + (p(0) * -1.0 + p(1) * 3.0 - p(2) * 3.0 + p(3)) * (1.0 / (h * h * h)) + } else { + (p(0) - p(1) * 3.0 + p(2) * 3.0 - p(3)) * (1.0 / (h * h * h)) + }; + assert!((left1 - d1).len() < 1e-7 * (1.0 + d1.len()), "the tangent is the secant: {left1:?} vs {d1:?}"); + assert!((left2 - d2).len() < 1e-4 * (1.0 + d2.len()), "the curvature is the second difference: {left2:?} vs {d2:?}"); + assert!((left3 - d3).len() < 2e-2 * (1.0 + d3.len()), "the change of curvature is the third difference: {left3:?} vs {d3:?}"); + } +} + +#[test] +fn a_shape_that_is_not_a_scaled_copy_still_runs_through() { + let n = 17; + let sections = vec![ + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + rounded_rect_section(9.0, 7.0, 2.5, 10.0, n), + circle_section(8.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ]; + let body = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The interior joint is still smooth to C² and holds the neighbours' own estimates, whatever + // the sections' shapes are. + for s in [0usize, 4, 8, 12, 16] { + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, _) = run_derivs(&body, 0.5, v, false, 2e-4); + let (_, right1, right2, _) = run_derivs(&body, 0.5, v, true, 2e-4); + assert!((left1 - right1).len() < 1e-6 * (1.0 + left1.len()), "station {s}: the tangent is continuous"); + assert!((left2 - right2).len() < 1e-6 * (1.0 + left2.len()), "station {s}: the curvature is continuous"); + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-6 * (1.0 + secant.len()), "station {s}: the tangent is the secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-6 * (1.0 + second.len()), "station {s}: the curvature is the second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn a_two_section_blend_is_the_bridge() { + let (a, b) = valley_between_cylinders(9, 10.0); + let made = loft_blend(&[a.clone(), b.clone()], [Continuity::G2, Continuity::G2], [0.0, 0.0], 9).unwrap(); + let other = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap(); + // The blend runs with the sections, the bridge crosses from one boundary to the other: the + // same face, its two parameters the other way round. + for (run, station) in [(0.0, 0.0), (0.25, 0.3), (0.5, 0.7), (0.75, 0.1), (1.0, 1.0)] { + let x = from_p3(surface_of(&made).subs(run, station)); + let y = from_p3(surface_of(&other).subs(station, run)); + assert!(x.dist(y) < 1e-9, "run {run} station {station}: {x:?} vs {y:?}"); + } + let grid = sample_grid(&other, 9, 7).unwrap(); + for (row, frame) in grid.iter().zip(&a) { + assert!(row[0].dist(frame.p) < 1e-9); + } +} + +#[test] +fn crown_shapes_the_middle_without_moving_the_joints() { + let n = 9; + let sections = three_circles(n); + let mut heights = Vec::new(); + let mut joints = Vec::new(); + for crown in [-0.5, 0.0, 1.0, 2.0] { + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [crown, crown], n).unwrap(); + // The interior joint holds the sections' own estimates whatever the crown does: those do + // not depend on it. + let (p, d1, d2, _) = run_derivs(&body, 0.5, 0.5, false, 2e-4); + assert!(p.dist(sections[1][4].p) < 1e-9, "the crown left the joint where the sections put it"); + joints.push((d1, d2)); + // Halfway up the first span is where a fuller crown reaches further. + heights.push(from_p3(surface_of(&body).subs(0.25, 0.5)).z); + assert_runs_through(&body, §ions, n); + } + for (d1, d2) in &joints { + let (base1, base2) = joints[0]; + assert!((*d1 - base1).len() < 1e-4 * (1.0 + base1.len()), "the crown left the joint's tangent alone: {d1:?}"); + assert!((*d2 - base2).len() < 1e-4 * (1.0 + base2.len()), "the crown left the joint's curvature alone: {d2:?}"); + } + let steps: Vec = heights.windows(2).map(|w| w[1] - w[0]).collect(); + assert!(steps.iter().all(|d| d.abs() > 1e-6 && d.signum() == steps[0].signum()), "a fuller crown moves the middle one way: {heights:?}"); +} + +#[test] +fn the_blend_refuses_what_it_cannot_hold() { + let n = 5; + let orders = [Continuity::G1, Continuity::G1]; + let good = three_circles(n); + assert!(loft_blend(&good, orders, [0.0, 0.0], n).is_ok()); + + // Fewer than two sections, and more than the cap. + assert!(loft_blend(&good[..1], orders, [0.0, 0.0], n).is_err(), "one section is no blend"); + let many: Vec> = + (0..=MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 3, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + assert!(loft_blend(&many, orders, [0.0, 0.0], 3).is_err(), "more than {MAX_BLEND_SECTIONS} sections"); + + // Station counts that do not match each other, or the count asked for. + let mut unequal = good.clone(); + unequal[1] = unequal[1][..n - 1].to_vec(); + assert!(loft_blend(&unequal, orders, [0.0, 0.0], n).is_err(), "unequal station counts"); + assert!(loft_blend(&good, orders, [0.0, 0.0], n + 1).is_err(), "the samples must match the sections"); + for samples in [0usize, 1, MAX_BLEND_STATIONS + 1] { + assert!(loft_blend(&good, orders, [0.0, 0.0], samples).is_err(), "samples {samples}"); + } + + // A crown outside its range, and one that is not a number. + for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] { + assert!(loft_blend(&good, orders, [crown, crown], n).is_err(), "crown {crown}"); + } + assert!(loft_blend(&good, orders, [0.0, f64::NAN], n).is_err()); + + // A frame that is not a number, wherever it is not, and a leave direction with no direction. + for pick in 0..4 { + let mut bad = good.clone(); + let frame = bad[1][2]; + bad[1][2] = match pick { + 0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frame }, + 1 => Frame { leave: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frame }, + 2 => Frame { bend: Vec3::new(0.0, f64::NAN, 0.0), ..frame }, + _ => Frame { bend_change: Vec3::new(0.0, 0.0, f64::NAN), ..frame }, + }; + assert!(loft_blend(&bad, orders, [0.0, 0.0], n).is_err(), "a non-finite frame {pick}"); + } + let mut no_leave = good.clone(); + no_leave[1][2].leave = Vec3::ZERO; + assert!(loft_blend(&no_leave, orders, [0.0, 0.0], n).is_err(), "a leave direction with no direction"); + + // Two sections that meet at a station: no room for a blend. + let mut meeting = good.clone(); + meeting[1] = meeting[0].clone(); + assert!(loft_blend(&meeting, orders, [0.0, 0.0], n).is_err(), "sections that meet"); + let mut touching = good.clone(); + touching[1][2].p = touching[0][2].p; + assert!(loft_blend(&touching, orders, [0.0, 0.0], n).is_err(), "a station where the sections meet"); + + // Every order from G0 to G3 builds, alone and together, and so does the largest blend. + for (near, far) in [ + (Continuity::G0, Continuity::G0), + (Continuity::G1, Continuity::G0), + (Continuity::G0, Continuity::G3), + (Continuity::G3, Continuity::G3), + (Continuity::G2, Continuity::G3), + ] { + let body = loft_blend(&good, [near, far], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, &good, n); + } + let full: Vec> = + (0..MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 5, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + let body = loft_blend(&full, [Continuity::G3, Continuity::G3], [0.0, 0.0], 5).unwrap(); + assert_runs_through(&body, &full, 5); +} diff --git a/crates/kernel/src/native/metadata.rs b/crates/kernel/src/native/metadata.rs index 1dccec7f..f87bd4ea 100644 --- a/crates/kernel/src/native/metadata.rs +++ b/crates/kernel/src/native/metadata.rs @@ -32,7 +32,8 @@ fn segment(s: &Seg2) -> Result { Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w }, Seg2::Bezier { n, p } => { let count = usize::from(n) + 1; - if !(5..=8).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) + { return Err(invalid("unsupported certificate Bezier layout")); } w::Segment::Bezier { points: p.get(..count).ok_or_else(|| invalid("Bezier count"))?.iter().copied().map(point2).collect() } @@ -46,10 +47,10 @@ fn load_segment(s: w::Segment) -> Result { w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) }, w::Segment::Conic { points: [a, apex, b], middle_weight } => Seg2::Conic { a: p2(a), apex: p2(apex), b: p2(b), w: middle_weight }, w::Segment::Bezier { points } => { - if !(5..=8).contains(&points.len()) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&points.len()) { return Err(invalid("certificate Bezier degree")); } - let mut p = [Vec2::ZERO; 8]; + let mut p = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; let n = (points.len() - 1) as u8; for (slot, value) in p.iter_mut().zip(points) { *slot = p2(value); @@ -324,7 +325,7 @@ mod tests { for value in sources { assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap()); } - assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; 9] }).is_err()); + assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; solidcraft_geom::MAX_BEZIER_CTRL + 1] }).is_err()); } #[test] diff --git a/crates/render/src/curve_display.rs b/crates/render/src/curve_display.rs index 08bbebf5..cd5a63cc 100644 --- a/crates/render/src/curve_display.rs +++ b/crates/render/src/curve_display.rs @@ -115,7 +115,9 @@ fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec>, H)> { Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]], Seg2::Cubic { p0, p1, p2, p3 } => vec![vec![h(p0, 1.0), h(p1, 1.0), h(p2, 1.0), h(p3, 1.0)]], Seg2::Conic { a, apex, b, w } if w > 0.0 => vec![vec![h(a, 1.0), h(apex, w), h(b, 1.0)]], - Seg2::Bezier { n, p } if (1..=7).contains(&n) => vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()], + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()] + } Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 => { diff --git a/crates/render/src/curve_display_tests.rs b/crates/render/src/curve_display_tests.rs index a3f45505..8aa62e69 100644 --- a/crates/render/src/curve_display_tests.rs +++ b/crates/render/src/curve_display_tests.rs @@ -27,7 +27,7 @@ fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath) #[test] fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() { - let mut degree7 = [Vec2::ZERO; 8]; + let mut degree7 = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, p) in degree7.iter_mut().enumerate() { *p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 }); } diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index 885b8786..66a7fb27 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -153,7 +153,7 @@ pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 { return p.first().copied().unwrap_or_default(); } if control { - return bspline_at(p, degree.clamp(1, 7) as usize, t); + return bspline_at(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize, t); } let spans = fit_spans(p); let n = spans.len().max(1); @@ -292,7 +292,7 @@ pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec { return p.to_vec(); } if control { - return bspline_polyline(p, degree.clamp(1, 7) as usize); + return bspline_polyline(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize); } let mut out = Vec::new(); for (p0, p1, t0, t1) in fit_spans(p) { diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs index 20adc640..6529cd06 100644 --- a/crates/sketch/src/freeform_offset.rs +++ b/crates/sketch/src/freeform_offset.rs @@ -137,7 +137,9 @@ impl H { let (points, weights) = match *seg { Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]) + } Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), _ => return Err(bad("unsupported exact free-form offset piece")), }; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 8a8c274a..819a4d3a 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -256,7 +256,7 @@ impl LinkGeom { (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) - && (1..=7).contains(degree) + && (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) } } } diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 823a17a3..f6651e71 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -1,6 +1,6 @@ use std::f64::consts::{FRAC_PI_2, PI}; -use solidcraft_geom::Vec2; +use solidcraft_geom::{Seg2, Vec2}; use crate::*; @@ -921,3 +921,49 @@ fn projected_boundary_keeps_its_pieces_when_only_stations_go() { assert!(sk.update_link(&j, &smaller).unwrap()); assert_eq!(sk.link_curves(&j).len(), 4); } + +#[test] +fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { + // A G4 blend curve is one degree-9 Bézier; a sketch keeps it as a control spline whose exact + // piece is that Bézier, and profile measurement must keep using the curve, not its chords. + let mut sk = Sketch::new(); + let bump = + [v(0.0, 0.0), v(1.5, 4.0), v(3.0, 7.0), v(5.0, 9.5), v(7.5, 11.0), v(12.5, 11.0), v(15.0, 9.5), v(17.0, 7.0), v(18.5, 4.0), v(20.0, 0.0)]; + let pts: Vec = bump.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); + let s = sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 9, periodic: false }, None).unwrap(); + let pieces = sk.exact_pieces(s); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + let Seg2::Bezier { n, p } = pieces[0] else { panic!("{:?}", pieces[0]) }; + assert_eq!(n, 9, "the highest degree a sketch piece can carry"); + for (i, q) in bump.iter().enumerate() { + assert!(p[i].dist(*q) < 1e-12, "control {i}"); + } + // Evaluation and the exact piece agree at the spline's real degree (a degree clamp would show + // here), and a line's own exact piece still comes back as a line. + let exact = pieces[0]; + for k in 0..=10 { + let t = k as f64 / 10.0; + assert!(crate::curves::spline_point(&bump, true, 9, t).dist(exact.point_at(t)) < 1e-9, "t={t}"); + } + let l = sk.add_line(v(-5.0, 0.0), v(-2.0, 1.0), None, None, None).unwrap(); + assert!(matches!(sk.exact_pieces(l).as_slice(), [Seg2::Line { .. }])); + // Close the loop: the region's area is the shoelace integral of the exact pieces, which a fine + // sampling of the piece reproduces (a chord approximation would miss the bump's sagitta). + sk.add_line(v(20.0, 0.0), v(0.0, 0.0), Some(pts[9]), Some(pts[0]), None).unwrap(); + let ps = find_profiles(&sk); + assert_eq!(ps.len(), 1, "{ps:?}"); + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let (a, b) = (exact.point_at(t0), exact.point_at(t1)); + sampled += 0.5 * a.cross(b); + } + sampled -= 0.5 * v(20.0, 0.0).cross(v(0.0, 0.0)); + assert!((ps[0].area - sampled.abs()).abs() < 1e-5, "{} vs {sampled}", ps[0].area); + assert!((ps[0].area - 100.0).abs() > 1e-3, "the region is not its chord: {}", ps[0].area); + // A degree above the cap is still refused. + let mut high: Vec = pts.clone(); + high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); + assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 7694ad36..74266488 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -621,7 +621,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K ], ) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, .. } => { (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()]) } FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e5d72eea..0b176461 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -682,10 +682,33 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + // Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the + // colour of its worst verdict. + for seam in &s.continuity { + let col = continuity_color(seam.verdict); + for w in seam.points.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + sc.line(a.to_f32(), b.to_f32(), col, 2.6, true); + } + } + } sc } +/// The colour of a measured seam: green within every tolerance, yellow where the faces only +/// meet at an angle (over G1), red where they do not meet at all or the curvature jumps across +/// them (over G2, the defect no chord tolerance shows). The greens, yellows and reds are the +/// ones the shader's own analyses use (`assets/shaders/viewport.wgsl`). +fn continuity_color(verdict: solidcraft_engine::kernel::SeamVerdict) -> [u8; 4] { + use solidcraft_engine::kernel::SeamVerdict as V; + match verdict { + V::Smooth => [51, 191, 77, 255], + V::Sharp => [242, 204, 38, 255], + V::CurvatureJump | V::Gap => [217, 56, 51, 255], + } +} + /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let tk = Tokens::get(); diff --git a/docs/screenshots/class-a-blend.png b/docs/screenshots/class-a-blend.png new file mode 100644 index 0000000000000000000000000000000000000000..59991f0ed4be837f2d474c0721cbe8c11296a9ee GIT binary patch literal 590527 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

hla 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 z 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%0Do-jzDC;<-S;NsxM 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%0AVeJZ!9BxBXt|g`LBrfal^<~0|k`LpUs|f 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Ð9ixbE3DNE`P^}I{%$Hom}8p^KJxVFIS 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

6`yQwA=q+Rxwl@I3su?f(QX6M)&n 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

DDlk;q%9yW#~ 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

m%V@U@a%I{&#d3w7ZFN~{I#^?yHB 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%0ApuCnLaumqmA=5p@a^c_|e5mJX_h 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�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

< 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 Date: Fri, 16 Oct 2026 06:26:31 -0400 Subject: [PATCH 007/149] Sketch: rectangles, circles and polygons draw by press, drag and release (#1630) (#1641) Thanks @DuBearte. --- crates/ui-egui/src/viewport.rs | 32 +++++++++++++++++++ .../two_point_shapes_drawn_by_dragging.json | 14 ++++++++ 2 files changed, 46 insertions(+) create mode 100644 crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 0b176461..ac7b5c02 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -40,6 +40,9 @@ pub enum NavMode { #[derive(Default)] pub struct ViewportState { + /// A two-point drawing tool's first point was placed by a press that became a drag: the + /// release places the second (#1630). + pub(crate) draw_drag: bool, /// Per-body boxes of one world state, held (not just its address, which a later state can /// reuse once the old one is freed, so stale boxes would come back). pick_bounds: std::sync::Mutex>, @@ -2277,12 +2280,41 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::frame_rate::paint(app, ui.ctx(), &painter, rect); // ---- interaction ---- + // A two-point shape being dragged out (#1630) follows the pointer and ends where it is let go, + // even over the shape's own dimension box, which rides along beside the pointer. + if app.viewport.draw_drag { + let at = ui.input(|i| i.pointer.latest_pos()).filter(|p| rect.contains(*p)); + if resp.drag_stopped() { + app.viewport.draw_drag = false; + if let Some(p) = at + && app.tool.as_ref().is_some_and(|t| t.pts.len() == 1) + { + crate::tools::on_hover(app, &proj, p); + crate::tools::on_click(app, &proj, p); + } + } else if let Some(p) = at { + crate::tools::on_hover(app, &proj, p); + } + } let add = mods.shift || mods.command || mods.ctrl; crate::sketch_text::set_one_letter(mods.shift); if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); } + // Rectangles, circles and polygons also draw by press, drag and release (#1630): the press + // places the first point as a click there would, the release the second. + let two_point_start = app.tool.as_ref().is_some_and(|t| matches!(t.kind, crate::tools::Kind::Draw(2)) && t.pts.is_empty()); + if two_point_start + && !held + && primary_nav.is_none() + && mode.is_none() + && resp.drag_started_by(egui::PointerButton::Primary) + && let Some(a) = ui.input(|i| i.pointer.press_origin()) + { + crate::tools::on_click(app, &proj, a); + app.viewport.draw_drag = app.tool.as_ref().is_some_and(|t| t.pts.len() == 1); + } if resp.clicked() && !held { if app.tool.is_some() { crate::tools::on_click(app, &proj, p); diff --git a/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json b/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json new file mode 100644 index 00000000..44e11fc8 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json @@ -0,0 +1,14 @@ +[ + {"note": "#1630: a rectangle or a circle draws by press, drag and release as well as by two clicks: the press places the first point, the release the second."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"start": "sketch.rectangle.two_point"}, + {"drag": [{"sketch": [10, 10]}, {"sketch": [40, 30]}], "steps": 8}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "construction"], "value": false}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4], "value": null}}}, + {"key": "Escape"}, + {"start": "sketch.circle.center"}, + {"drag": [{"sketch": [-30, 10]}, {"sketch": [-20, 10]}], "steps": 8}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "construction"], "value": false}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5], "value": null}}} +] From 4d58d3bbca9bd2bc7b05c2de03e1e022b37cf79d Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:26:35 -0400 Subject: [PATCH 008/149] Extrude: to an object through the body, the automatic operation is Cut (#1633) (#1636) Thanks @DuBearte. --- crates/engine/src/cmd/features.rs | 20 ++++++++++++++++---- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 16 +++++++++------- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index ed5843a3..fa07dd15 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -141,6 +141,20 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { Some((ss.plane.to_world(q), ss.plane.normal())) } +/// How far an extrude with these parameters goes from its profile along the profile's normal +/// (negative: the other way). To an object, the target's height, on whichever side it is: the +/// extrude goes there and its `direction` does not count (#1633). Otherwise its distance, turned +/// round for the negative direction. +pub fn extrude_reach(s: &Session, p: &Value) -> Option { + if let Some(t) = p.get("to").and_then(vec3) { + let (base, n) = extrude_base(s, p)?; + return Some((t - base).dot(n)).filter(|h| h.is_finite()); + } + let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; + let dir = str_(p, "direction").map(str::to_ascii_lowercase); + Some(if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d }) +} + /// How far past the profile [`auto_operation`] looks for material it starts in. const START_PROBE_MM: f64 = 0.1; @@ -152,10 +166,8 @@ const START_PROBE_MM: f64 = 0.1; /// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far /// past a body's far side read as a Join (#1257). pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { - let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; - let dir = str_(p, "direction").map(str::to_ascii_lowercase); - let symmetric = dir.as_deref() == Some("symmetric"); - let d = if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d }; + let d = extrude_reach(s, p)?; + let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric")); let (base, n) = extrude_base(s, p)?; let st = s.model.state(); if st.bodies.is_empty() || !d.is_finite() { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a37f226a..34883809 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -33,7 +33,7 @@ mod edit; mod face; mod features; mod features_more; -pub use features::auto_operation; +pub use features::{auto_operation, extrude_reach}; mod component_link; mod config; mod file; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b5c2da8d..6b60a557 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,7 +31,7 @@ use serde_json::Value; use solidcraft_doc::{Document, Model, ModelState}; use solidcraft_geom::Vec3; -pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command}; +pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, extrude_reach, find_command}; pub use solidcraft_doc as doc; pub use solidcraft_doc::Visibility; pub use solidcraft_geom as geom; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d0be87f3..c5c419f8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1196,6 +1196,27 @@ fn extrude_auto_operation() { assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6); } +/// #1633: to an object, the extrude goes to it on whichever side it is, whatever its +/// `direction`: through the body to its far face is a Cut, out to something above is a Join. +#[test] +fn extrude_auto_operation_follows_the_to_object() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "corner": [0, 0, 50], "body_name": "Above"})); + for direction in ["positive", "negative"] { + // From the top face to the bottom face: through the body. + let down = json!({"face": [20, 15, 20], "to": [10, 10, 0], "distance": 19, "direction": direction}); + assert!(extrude_reach(&s, &down).is_some_and(|h| (h + 20.0).abs() < 1e-6), "{down}"); + assert_eq!(auto_operation(&s, &down), Some("cut"), "{down}"); + // From the top face up to the box above: out of the body. + let up = json!({"face": [20, 15, 20], "to": [10, 10, 50], "distance": 19, "direction": direction}); + assert!(extrude_reach(&s, &up).is_some_and(|h| (h - 30.0).abs() < 1e-6), "{up}"); + assert_eq!(auto_operation(&s, &up), Some("join"), "{up}"); + } + // Without a To object the direction still counts. + assert_eq!(extrude_reach(&s, &json!({"face": [20, 15, 20], "distance": 5, "direction": "negative"})), Some(-5.0)); +} + /// Additive extrusions default to joining even when only a profile side touches material. #[test] fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dbe7edf6..10219146 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2054,16 +2054,18 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { } let Kind::Extrude { direction, distance, .. } = &d.kind else { return }; let Ok(dist) = app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length) else { return }; - let side = crate::smart_extrude::side(dist, *direction); + let mut side = crate::smart_extrude::side(dist, *direction); if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) { return; } - let Some(fresh) = dialog_commands(app, d) - .ok() - .and_then(|c| c.into_iter().next()) - .and_then(|(_, p)| solidcraft_engine::auto_operation(&app.session, &p)) - .and_then(|op| OPS.iter().position(|o| *o == op)) - else { + let Some(params) = dialog_commands(app, d).ok().and_then(|c| c.into_iter().next()).map(|(_, p)| p) else { return }; + // To an object, the extrude goes to it on whichever side it is: that is the side (#1633). + if params.get("to").is_some() + && let Some(reach) = solidcraft_engine::extrude_reach(&app.session, ¶ms) + { + side = crate::smart_extrude::side(reach, 0); + } + let Some(fresh) = solidcraft_engine::auto_operation(&app.session, ¶ms).and_then(|op| OPS.iter().position(|o| *o == op)) else { return; }; if let Kind::Extrude { operation, smart, .. } = &mut d.kind { From 374ec835055adf6542cb5419b54494a4ecebe4aa Mon Sep 17 00:00:00 2001 From: Igor Bronovskyi Date: Fri, 16 Oct 2026 13:26:40 +0300 Subject: [PATCH 009/149] Add the Ukrainian translations for the messages added after #1196 (#1632) Thanks @BrunIF. --- assets/locales/uk.json | 343 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) diff --git a/assets/locales/uk.json b/assets/locales/uk.json index bd52bef4..86c162c3 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -5,6 +5,7 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Додайте знімок екрана: скопіюйте й вставте його або збережіть і завантажте PNG.", "3. Click Submit new issue on GitHub to send your report.": "3. Натисніть Submit new issue на GitHub, щоб надіслати звіт.", "A new configuration, a copy of the active one": "Нова конфігурація, копія активної", + "AI": "ШІ", "About SolidCraft": "Про SolidCraft", "Active": "Активна", "Active rule": "Активне правило", @@ -24,6 +25,7 @@ "Applied Radius": "Застосований радіус", "Apply To": "Застосувати до", "Apply to selection": "Застосувати до виділення", + "Arc length": "Довжина дуги", "Area": "Площа", "Attachment": "Прив'язка", "BROWSER": "БРАУЗЕР", @@ -41,10 +43,12 @@ "Center X": "Центр X", "Center Y": "Центр Y", "Center of mass": "Центр мас", + "Centre to centre": "Від центра до центра", "Chamfer Type": "Тип фаски", "Check collisions": "Перевіряти зіткнення", "Checked": "Перевірено", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Виберіть нову площину. Геометрія та розміри ескізу залишаються незмінними у своїх двовимірних координатах.", + "Circumference": "Довжина кола", "Close draft": "Закрити чернетку", "Collisions": "Зіткнення", "Colour": "Колір", @@ -54,6 +58,8 @@ "Construction": "Конструкційна", "Contact": "Контакт", "Continuity": "Неперервність", + "Copy Error Message": "Копіювати повідомлення про помилку", + "Copy Warning Message": "Копіювати попереджувальне повідомлення", "Copy all licences": "Копіювати всі ліцензії", "Copy diagnostic information": "Копіювати діагностичну інформацію", "Copy report": "Копіювати звіт", @@ -64,6 +70,8 @@ "Countersink angle": "Кут зенкування", "Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки", "Cube": "Куб", + "Current orthographic view (1:1 mm)": "Поточний ортографічний вид (1:1 mm)", + "Current perspective view": "Поточний перспективний вид", "Curvature (G2)": "Кривина (G2)", "Customize Toolbar": "Налаштувати панель інструментів", "Delete all": "Видалити все", @@ -75,6 +83,7 @@ "Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.", "Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.", "Discard": "Відхилити", + "Do not export hidden edges": "Не експортувати приховані ребра", "Double-click to rename": "Двічі клацніть, щоб перейменувати", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Подвійне клацання середньою кнопкою у виді: головний (головний вид, що охоплює показане), вписування (охопити показане, зберігаючи орієнтацію) або нічого", "Drive Joint": "Керувати з'єднанням", @@ -83,7 +92,12 @@ "Enable rule": "Увімкнути правило", "Enabled": "Увімкнено", "Explode": "Рознести", + "Export Current View as DXF": "Експортувати поточний вид як DXF", + "Export Current View as DXF…": "Експортувати поточний вид як DXF…", + "Export DXF…": "Експорт DXF…", + "Export hidden edges as dashed lines": "Експортувати приховані ребра як штрихові лінії", "Extents": "Межі", + "Face": "Грань", "Face attachment lost": "Втрачено прив'язку до грані", "Faces": "Грані", "Faces / edges / vertices": "Грані / ребра / вершини", @@ -100,8 +114,12 @@ "Gap": "Проміжок", "Grid": "Сітка", "Height": "Висота", + "Hidden edges": "Приховані ребра", + "Holes": "Отвори", + "Holes (edges on them)": "Отвори (ребра на них)", "Home view": "Головний вид", "In This Design": "У цьому проєкті", + "In place (surfaces)": "На місці (поверхні)", "Inside": "Усередину", "Interferes": "Перетинається", "Issue title": "Заголовок повідомлення", @@ -119,8 +137,11 @@ "Margin": "Відступ", "Mass": "Маса", "Material": "Матеріал", + "Max": "Макс.", "Mid Radius": "Середній радіус", "Middle double-click": "Подвійне клацання середньою кнопкою", + "Min": "Мін.", + "Mitre": "Скіс", "Modeled": "Змодельовано", "More toolbar panels": "Більше панелей інструментів", "Motion": "Рух", @@ -130,6 +151,8 @@ "Name": "Назва", "No crash reports.": "Немає звітів про збої.", "No matching appearances": "Немає відповідних виглядів", + "No visible bodies to export.": "Немає видимих тіл для експорту.", + "None": "Немає", "Not available yet": "Ще недоступно", "Notch": "Виріз", "Opacity": "Непрозорість", @@ -137,11 +160,13 @@ "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Параметричний 3D-CAD з відкритим вихідним кодом на Rust. Ліцензовано за MIT OR Apache-2.0.", "Other side": "Інший бік", "Outside": "Назовні", + "Overall length": "Загальна довжина", "Parameter values": "Значення параметрів", "Pass through": "Через точки", "Path": "Траєкторія", "Per Edge": "На кожне ребро", "Pick the tool bodies": "Виберіть тіла інструмента", + "Picked": "Вибрано", "Place at the origin instead": "Натомість розмістити в початку координат", "Playback": "Відтворення", "Position": "Положення", @@ -151,13 +176,16 @@ "Preview Row": "Рядок перегляду", "Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.", "Profiles/Curves": "Профілі/криві", + "Projection": "Проєкція", "RECENT": "НЕДАВНІ", + "RULED SURFACE": "ЛІНІЙЧАСТА ПОВЕРХНЯ", "Radius Fit": "Підгонка за радіусом", "Ratio": "Співвідношення", "Reattach selected face": "Повторно приєднати вибрану грань", "Recover": "Відновити", "Recover unsaved design?": "Відновити незбережений проєкт?", "Rectangular": "Прямокутний", + "Reference face": "Опорна грань", "Remove Tools": "Прибрати інструменти", "Remove from list": "Прибрати зі списку", "Remove override": "Прибрати перевизначення", @@ -168,6 +196,7 @@ "Remove this point": "Прибрати цю точку", "Report a bug — review a screenshot and open a GitHub draft": "Повідомити про помилку — переглянути знімок екрана й відкрити чернетку GitHub", "Reset Toolbar": "Скинути панель інструментів", + "Restore outer boundary": "Відновити зовнішню межу", "Restore the inherited component or physical material appearance": "Відновити успадкований вигляд компонента або фізичного матеріалу", "Result": "Результат", "Retake screenshot": "Повторити знімок екрана", @@ -204,6 +233,7 @@ "Sketch geometry and profiles": "Геометрія та профілі ескізу", "Sketch plane has no body-face attachment.": "Площина ескізу не має прив'язки до грані тіла.", "Sketch points": "Точки ескізу", + "Slot width": "Ширина паза", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.", "Speed": "Швидкість", "Stand it up": "Вирівняти", @@ -230,7 +260,9 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ця грань не може забезпечити надійну прив'язку. Виберіть іншу грань або явно використайте фіксовану площину.", "To Offset": "До зсуву", "Tolerance": "Допуск", + "Turn off section analysis before exporting the view.": "Вимкніть аналіз перерізу перед експортом виду.", "Type": "Тип", + "UNTRIM": "ЗНЯТИ ОБРІЗАННЯ", "Updating the preview": "Оновлення попереднього перегляду", "Use active author placement": "Використати активне вихідне розміщення", "Use fixed plane": "Використати фіксовану площину", @@ -240,6 +272,7 @@ "Value": "Значення", "Vertices": "Вершини", "View navigator": "Навігатор виду", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Лише видимі тіла. Криві подаються відрізками (допуск 0,01 mm).", "Volume": "Об'єм", "Wall": "Стінка", "Wall Location": "Розташування стінки", @@ -248,6 +281,12 @@ "Width": "Ширина", "Will fail": "Зазнає невдачі", "angle, height": "кут, висота", + "appearance.dielectric_ior": "Діелектричний IOR", + "appearance.metallic": "Металевість", + "appearance.pbr_help": "Перегляньте цю поверхню в режимі рендерингу або як PNG-зображення виробу. Без заломлення чи пропускання.", + "appearance.pbr_opaque": "Непрозорий PBR", + "appearance.roughness": "Шорсткість", + "appearance.surface_response": "Відгук поверхні", "browser.body_name": "Тіло{number}", "browser.component_name": "Компонент{number}", "browser.parameters": "Параметри ({count})", @@ -326,6 +365,9 @@ "cmd.doc_recovery_discard": "Відхилити відновлений проєкт", "cmd.doc_recovery_list": "Проєкти для відновлення", "cmd.document_inspect": "Перевірити проєкт", + "cmd.document_metadata": "Властивості проєкту", + "cmd.document_metadata_remove": "Видалити властивість проєкту", + "cmd.document_metadata_set": "Змінити властивості проєкту", "cmd.document_units": "Змінити одиниці проєкту", "cmd.edit_checkpoint": "Контрольна точка", "cmd.edit_restore_checkpoint": "Відновити контрольну точку", @@ -340,6 +382,7 @@ "cmd.file_insert_mesh": "Вставити сітку", "cmd.file_insert_step": "Вставити STEP", "cmd.file_save_mesh": "Зберегти як сітку", + "cmd.file_slicers": "Встановлені слайсери", "cmd.inspect_accessibility": "Аналіз доступності", "cmd.inspect_center_of_mass": "Центр мас", "cmd.inspect_compare": "Порівняти тіла", @@ -417,6 +460,15 @@ "cmd.plastic_rules": "Список правил пластику", "cmd.plastic_snap_fit": "Клацкове з'єднання", "cmd.render_backends": "Рендер-бекенди", + "cmd.render_cancel": "Скасувати рендеринг", + "cmd.render_export": "Експортувати зображення рендерингу", + "cmd.render_preflight": "Перевірити запит на рендеринг", + "cmd.render_scene": "Переглянути сцену рендерингу", + "cmd.render_settings_apply": "Застосувати камеру рендерингу", + "cmd.render_settings_get": "Налаштування рендерингу", + "cmd.render_settings_set": "Зберегти налаштування рендерингу", + "cmd.render_start": "Рендеринг зображення", + "cmd.render_status": "Перебіг рендерингу", "cmd.select_clear": "Зняти виділення", "cmd.sheet_convert": "Перетворити на листовий метал", "cmd.sheet_export_bend_table": "Експортувати таблицю згинів", @@ -564,6 +616,7 @@ "coupling.rack": "Рейка", "coupling.radius_1": "Радіус 1", "coupling.radius_2": "Радіус 2", + "dialog.computing": "Обчислення… {seconds} с", "dialog.custom_axis": "↖ Власна вісь", "dialog.edit_title": "РЕДАГУВАННЯ {title}", "dialog.select": "↖ Вибрати", @@ -573,10 +626,25 @@ "dialog.step": "Крок", "document.save_changes": "Зберегти зміни в {name}?", "document.unsaved_changes": "У {name} є незбережені зміни. Якщо не зберегти, вони будуть втрачені.", + "drive.stopped_by_contact": "Зупинка на {value}: {a} пройшло б крізь {b}", + "drive.stopped_by_loop": "Зупинка на {value}: контур через {joint} далі не замикається", "drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим", "expression, e.g. 25 mm": "вираз, напр. 25 mm", "fits the face, e.g. M8": "відповідає грані, напр. M8", "from the rule": "з правила", + "home.documentation": "Документація", + "home.in_this_browser": "у цьому браузері", + "home.missing": "відсутній", + "home.open": "Відкрити…", + "home.opened_days_ago": "Відкрито {count} дн. тому", + "home.opened_hours_ago": "Відкрито {count} год тому", + "home.opened_just_now": "Щойно відкрито", + "home.opened_minutes_ago": "Відкрито {count} хв тому", + "home.opening": "відкриття…", + "home.recent": "Недавні", + "home.report_issue": "Повідомити про проблему", + "home.samples": "Зразки", + "home.whats_new": "Що нового", "install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі", "install.dock_1": "Виберіть «Файл» › «Додати в Dock».", "install.dock_2": "Натисніть «Додати».", @@ -592,6 +660,12 @@ "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.", "joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.", "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", + "nav.touchpad.help": "Утримуйте Shift і рухайте вказівник, щоб панорамувати; утримуйте Alt/Option і рухайте, щоб обертати. Кнопка миші не потрібна. Вертикальна прокрутка масштабує.", + "nav.touchpad.label": "Трекпад", + "nav.touchpad.orbit": "Обертання (утримуйте Alt/Option і рухайте; без кнопки миші)", + "nav.touchpad.pan": "Панорамування (утримуйте Shift і рухайте; без кнопки миші)", + "nav.touchpad.scroll": "Трекпад використовує вертикальну прокрутку для масштабування. Ваше налаштування панорамування двома пальцями зберігається для інших наборів.", + "nav.touchpad.zoom": "Масштаб (вертикальна прокрутка або стискання)", "needs two joints": "потрібні два з'єднання", "new configuration": "нова конфігурація", "new name": "нова назва", @@ -606,6 +680,60 @@ "pattern.flip_direction": "Змінити напрямок", "pick a hole to seat it, or OK places it at the origin": "виберіть отвір для посадки, або OK розмістить його в початку координат", "pick one or two vertices, edges or faces": "виберіть одну-дві вершини, ребра або грані", + "render.background": "Фон сцени", + "render.fit": "Вписати виріб", + "render.image_cancel": "Скасувати рендеринг", + "render.image_cancelled": "Рендеринг скасовано", + "render.image_cancelling": "Скасування", + "render.image_check": "Перевірити запит на рендеринг", + "render.image_completed": "Зображення готове до експорту", + "render.image_encoding": "Кодування PNG", + "render.image_export": "Експортувати PNG", + "render.image_failed": "Рендеринг не вдався", + "render.image_height": "Висота", + "render.image_help": "Попереднє зображення на CPU · PNG · наближена прозорість", + "render.image_overwrite": "Замінити наявний файл", + "render.image_preparing": "Підготовка зображення", + "render.image_queued": "Очікує рендерингу", + "render.image_reduce": "Спробуйте менше зображення або простішу сцену.", + "render.image_running": "Рендеринг зображення", + "render.image_stale": "Це зображення використовує сцену, зафіксовану до подальших змін.", + "render.image_start": "Почати рендеринг", + "render.image_supported": "Запит на рендеринг підтримується; чергу та пул пам'яті буде перевірено ще раз під час запуску.", + "render.image_title": "Рендеринг зображення", + "render.image_too_large": "Перевищено ліміт зображення: 4096 пікселів на сторону та 4 194 304 пікселі загалом.", + "render.image_transparent": "Прозорий фон", + "render.image_unknown": "Стан рендерингу недоступний", + "render.image_use_largest": "Використати найбільший розмір за такого співвідношення сторін:", + "render.image_width": "Ширина", + "render.navigation_help": "Камера рухається так само, як у режимі проєктування (Налаштування › Навігація). «Зберегти налаштування» фіксує поточну камеру.", + "render.opaque_image_summary": "Попередній PNG · непрозорий PBR · змішана прозорість", + "render.opaque_preview_help": "Непрозорий PBR і прості кольори; змішана прозорість. Заломлення, HDRI та фінальний рендеринг поки недоступні.", + "render.perspective": "Перспективна камера", + "render.preview": "Вбудований попередній перегляд", + "render.preview_help": "Растрове затінення та змішана прозорість. Заломлення, HDRI та фінальний рендеринг поки недоступні.", + "render.reload_settings": "Перезавантажити збережені налаштування", + "render.restore_camera": "Відновити збережену камеру", + "render.return_design": "Повернутися до проєктування", + "render.save_setup": "Зберегти налаштування", + "render.setup_name": "Назва налаштування", + "render.unavailable_preview": "Це налаштування вимагає недоступного профілю попереднього перегляду.", + "render.unsaved": "Незбережені зміни попереднього перегляду", + "render.use_preview": "Використати вбудований попередній перегляд", + "sample.bracket.about": "Основна полиця з кромковими полицями та загином за правилом 2 mm", + "sample.bracket.name": "Кронштейн з листового металу", + "sample.enclosure.about": "Оболонковий корпус із буртиком, бобишками під гвинти, упором і клацковим з'єднанням", + "sample.enclosure.name": "Пластиковий корпус", + "sample.flange.about": "Маточина й фланець із круговим масивом, керованим параметром", + "sample.flange.name": "Болтовий фланець", + "sample.hinge.about": "Два компоненти та обертальне з'єднання, доведене до 60°", + "sample.hinge.name": "Петльова збірка", + "sample.plate.about": "Параметрична пластина із заокругленнями, отворами та розточеною бобишкою", + "sample.plate.name": "Зразкова пластина", + "sample.vents.about": "Пази в прямокутному масиві: рядки, стовпці та крок є параметрами", + "sample.vents.name": "Вентиляційна пластина", + "settings.display_quality_note": "Дрібніші рівні малюють криві грані й ребра плавнішими, але тріангуляція триває довше. Змінюється лише вікно перегляду, ніколи не проєкт чи експорт.", + "settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.", "settings.measure_axis_distances": "Відстані X/Y/Z", "settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.", "settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft", @@ -613,6 +741,7 @@ "settings.msaa_unsupported": "Не підтримується цим графічним пристроєм", "settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б SolidCraft запуститися, тому їх змінено:", "settings.reset_title": "Налаштування було змінено", + "settings.supersampling_note": "Малює вид у вищій роздільності й потім зменшує його: плавніші ребра, лінії та сітка на екранах до 4K. Це коштує часу GPU: приблизно 2,25× пікселів за 1,5× і 4× за 2×.", "sketch.degrees_of_freedom": "{count} ступенів вільності", "sketch.draw_curves": "Малювати як {linetype}", "sketch.make_selection": "Перетворити виділене на {linetype}", @@ -636,6 +765,9 @@ "timeline.reorder_many": "Перевпорядкувати операції", "timeline.ungroup": "Прибрати теки операцій", "to the next face": "до наступної грані", + "toolbar.image": "ЗОБРАЖЕННЯ", + "toolbar.render": "РЕНДЕР", + "toolbar.render_scene": "СЦЕНА", "trace.points": "{count} точок на траєкторії", "trace.stopped_by_contact": "Закінчується на відліку {sample}: {a} пройшов би крізь {b}", "trace.stopped_by_loop": "Закінчується на відліку {sample}: контур через {joint} далі не замикається", @@ -646,8 +778,162 @@ "ui.about": "Про програму", "ui.activate": "Активувати", "ui.active_component": "Активний компонент", + "ui.agent_chat": "Чат з агентом", + "ui.agent_chat_allow": "Дозволити", + "ui.agent_chat_allowed": "Дозволено", + "ui.agent_chat_answered": "Відповідь: {option}", + "ui.agent_chat_approval": "Дозволити агенту це зробити?", + "ui.agent_chat_attach_view": "Прикріпити вид", + "ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням", + "ui.agent_chat_auth_agent": "Увійти через {name}", + "ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.", + "ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.", + "ui.agent_chat_auth_none": "Агент тут не пропонує способу входу: увійдіть так, як описано в його власній документації.", + "ui.agent_chat_auth_retry": "Я ввійшов: під'єднатися знову", + "ui.agent_chat_auth_terminal": "Виконайте це в терміналі, а потім знову натисніть «Під'єднатися»:", + "ui.agent_chat_auth_title": "Вхід до агента", + "ui.agent_chat_cancel": "Скасувати", + "ui.agent_chat_changes": "Зміни: {n}, один крок скасування", + "ui.agent_chat_changes_of": "{n}/{limit} змін", + "ui.agent_chat_close": "Закрити", + "ui.agent_chat_command": "Команда агента", + "ui.agent_chat_connect": "Під'єднатися", + "ui.agent_chat_copy": "Копіювати", + "ui.agent_chat_custom_hint": "Будь-яка програма, що підтримує Agent Client Protocol через stdin і stdout. Оболонка її не запускає.", + "ui.agent_chat_denied": "Відхилено", + "ui.agent_chat_deny": "Відхилити", + "ui.agent_chat_desktop_only": "Чат з агентом потребує настільної програми.", + "ui.agent_chat_detecting": "Пошук агентів…", + "ui.agent_chat_dock_left": "Закріпити ліворуч", + "ui.agent_chat_dock_right": "Закріпити праворуч", + "ui.agent_chat_edit": "Редагувати", + "ui.agent_chat_exited": "Агент зупинився.", + "ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}", + "ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.", + "ui.agent_chat_key_in_env": "ключ задано", + "ui.agent_chat_left": "Ліворуч", + "ui.agent_chat_limit": "Агент досяг межі {limit} змін на одне повідомлення. Надішліть інше повідомлення, щоб дозволити йому продовжити.", + "ui.agent_chat_minimize": "Згорнути до смужки", + "ui.agent_chat_model_tip": "Модель, яку використовує агент. Ваш вибір запам'ятовується для цього агента.", + "ui.agent_chat_new": "Новий чат", + "ui.agent_chat_new_confirm": "Почати новий чат? Поточний буде очищено.", + "ui.agent_chat_new_yes": "Новий чат", + "ui.agent_chat_no_cli": "solidcraft-cli не знайдено поруч із програмою, тож агент не може дістатися до проєкту.", + "ui.agent_chat_no_control": "Кінцева точка чату з агентом не запустилася, тож агент не може дістатися до проєкту.", + "ui.agent_chat_no_images": "Цей агент не приймає зображень", + "ui.agent_chat_none_found": "Агента для програмування не знайдено. Установіть один із цих, а потім натисніть «Оновити»:", + "ui.agent_chat_none_found_short": "Агента не знайдено", + "ui.agent_chat_not_installed": "Не встановлено", + "ui.agent_chat_not_signed_in": "не виконано вхід", + "ui.agent_chat_permission": "Агент просить виконати: {title}", + "ui.agent_chat_pick": "Агент", + "ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…", + "ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)", + "ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.", + "ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.", + "ui.agent_chat_queue": "У чергу", + "ui.agent_chat_queue_full": "Забагато повідомлень в очікуванні: приберіть одне або зачекайте на агента.", + "ui.agent_chat_queued": "У черзі", + "ui.agent_chat_queued_stopped": "У черзі (зупинено, не надіслано)", + "ui.agent_chat_refresh": "Оновити", + "ui.agent_chat_refused": "Не дозволено для агента: {reason}", + "ui.agent_chat_remove": "Прибрати з черги", + "ui.agent_chat_right": "Праворуч", + "ui.agent_chat_save": "Зберегти", + "ui.agent_chat_send": "Надіслати", + "ui.agent_chat_send_now": "Надіслати зараз", + "ui.agent_chat_send_now_tip": "Зупинити поточний крок агента й надіслати це наступним (Ctrl+Enter)", + "ui.agent_chat_side": "Сторона чату з агентом", + "ui.agent_chat_signed_in": "виконано вхід", + "ui.agent_chat_state_auth": "Потрібен вхід", + "ui.agent_chat_state_busy": "Працює…", + "ui.agent_chat_state_exited": "Агент зупинився", + "ui.agent_chat_state_off": "Не під'єднано", + "ui.agent_chat_state_ready": "Готово", + "ui.agent_chat_state_starting": "Запуск…", + "ui.agent_chat_stop": "Зупинити", + "ui.agent_chat_stop_tip": "Зупинити агента й усе, що він запустив", + "ui.agent_chat_stop_turn_tip": "Зупинити агента (Esc). Повідомлення в черзі залишаються в черзі.", + "ui.agent_chat_turn_limit": "Змін на повідомлення", + "ui.agent_chat_undo_changes": "Скасувати ці зміни", + "ui.agent_chat_undo_label": "Чат з агентом: {message}", + "ui.agent_chat_undone": "(скасовано)", + "ui.agent_chat_view_attached": "Вид прикріплено", "ui.agent_cursor": "Курсор агента", "ui.agent_cursor_speed": "Швидкість курсора агента", + "ui.ai_add": "Додати", + "ui.ai_add_agent": "Додати агента…", + "ui.ai_add_agent_title": "Додати агента", + "ui.ai_add_api": "API, сумісний з OpenAI", + "ui.ai_add_custom": "Власна команда", + "ui.ai_add_installed": "Встановлений агент", + "ui.ai_add_installed_note": "Ваш власний запис для встановленого агента, наприклад з іншими аргументами. Встановлені агенти перелічуються окремо.", + "ui.ai_added": "додано", + "ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти SolidCraft підкоряються дозволам у Налаштування > ШІ.", + "ui.ai_agents": "Агенти", + "ui.ai_always_allow": "Завжди дозволяти", + "ui.ai_always_allow_tip": "Дозволяти {tool} для цього агента відтепер без запиту", + "ui.ai_always_allowed": "Завжди дозволено", + "ui.ai_always_allowed_none": "Поки нічого не дозволено завжди. Відповіді «Завжди дозволяти», які ви даєте в чаті, перелічуються тут.", + "ui.ai_auto_allowed": "Автоматично дозволено: {title} ({why})", + "ui.ai_auto_mode": "ваше налаштування дозволів", + "ui.ai_auto_ours": "SolidCraft сам перевіряє власні інструменти", + "ui.ai_auto_remembered": "ви вибрали «Завжди дозволяти»", + "ui.ai_bad_url": "Введіть URL-адресу, що починається з http:// або https://", + "ui.ai_base_url": "Базова URL-адреса", + "ui.ai_button": "ШІ", + "ui.ai_copy_suffix": "копія", + "ui.ai_custom_url": "Власна URL-адреса", + "ui.ai_default": "Типовий", + "ui.ai_http_busy": "кінцева точка зайнята або перевищила ліміт; спробуйте пізніше", + "ui.ai_http_key": "кінцева точка відхилила ключ: перевірте його в Налаштування > ШІ", + "ui.ai_http_not_found": "немає такої кінцевої точки чи моделі: перевірте базову URL-адресу й назву моделі", + "ui.ai_key": "Ключ API", + "ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів", + "ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.", + "ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях SolidCraft. Залиште порожнім для локального сервера.", + "ui.ai_keychain_error": "Системне сховище ключів відмовило: {error}", + "ui.ai_mode_all": "Дозволяти все", + "ui.ai_mode_all_note": "Ніщо не запитує заздалегідь. Звіти про збої залишаються поза межами, а обмеження тек залишаються чинними.", + "ui.ai_mode_all_warning": "Агент може зберігати, перезаписувати, експортувати й замінювати ваш проєкт без запиту. Використовуйте це лише з агентом, якому довіряєте, і на збережених проєктах.", + "ui.ai_mode_ask": "Запитувати щоразу", + "ui.ai_mode_ask_note": "Кожна зміна проєкту та кожна дія з файлами чекає на ваш дозвіл.", + "ui.ai_mode_edits": "Автоматично дозволяти редагування моделі", + "ui.ai_mode_edits_note": "Команди моделювання виконуються без запиту; збереження, відкриття, експорт та інші файлові чи руйнівні дії все ще запитують.", + "ui.ai_mode_global": "Як задано вище", + "ui.ai_model": "Модель", + "ui.ai_modes_scope": "Ці налаштування стосуються інструментів SolidCraft. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.", + "ui.ai_name": "Назва", + "ui.ai_need_command": "Введіть команду, що запускає агента", + "ui.ai_need_model": "Введіть назву моделі", + "ui.ai_no_answer": "Агент не відповів вчасно", + "ui.ai_no_http": "Ця збірка не може дістатися до кінцевих точок API", + "ui.ai_no_keychain": "Системне сховище ключів недоступне, тож ключ не можна зберегти. Локальний сервер без ключа все одно працює.", + "ui.ai_panel": "Панель", + "ui.ai_permissions": "Дозволи", + "ui.ai_preset": "Набір", + "ui.ai_remove": "Видалити", + "ui.ai_request_limit": "зупинено після 24 запитів на одне повідомлення; надішліть інше повідомлення, щоб продовжити", + "ui.ai_reset": "Скинути", + "ui.ai_settings": "Налаштування ШІ…", + "ui.ai_show_features": "Показувати функції ШІ", + "ui.ai_show_features_note": "Чат з агентом: панель, де агент для програмування, яким ви вже користуєтеся, або обрана вами модельна кінцева точка працює з відкритим проєктом. Вимкнено: немає кнопки ШІ, комбінації клавіш, пункту меню чи панелі, і нічого не шукається та не запускається.", + "ui.ai_switch_tip": "Змінити агента для цієї розмови", + "ui.ai_test": "Перевірити", + "ui.ai_test_acp_ok": "Відповідає: {agent}", + "ui.ai_test_acp_tip": "Ненадовго запускає агента й запитує сесію; потім завершує його", + "ui.ai_test_api_tip": "Запитує в кінцевої точки її моделі", + "ui.ai_test_no_model": "Досягнуто: {count} моделей, але не {model}", + "ui.ai_test_ok": "Досягнуто: {count} моделей", + "ui.ai_testing": "Перевірка…", + "ui.ai_tool_done": "готово", + "ui.ai_tool_failed": "не вдалося", + "ui.ai_tool_running": "виконується", + "ui.ai_tool_unfold": "Клацніть, щоб показати, що він повернув", + "ui.ai_tool_waiting": "очікування", + "ui.ai_toolbar_button": "Показувати кнопку на панелі інструментів", + "ui.ai_toolbar_button_note": "Кнопка ШІ в правому кінці панелі інструментів. Ctrl+Shift+A і меню «Вид» усе одно відкривають панель.", + "ui.ai_undo_tip": "Скасувати все, що агент змінив для цього повідомлення, одним кроком", "ui.all": "Усі", "ui.analysis": "Аналіз", "ui.angle": "Кут", @@ -664,6 +950,7 @@ "ui.axes": "Осі", "ui.axis": "Вісь", "ui.base_point": "Базова точка", + "ui.blocked": "Заблоковані", "ui.bodies": "Тіла", "ui.box": "Куб", "ui.break": "Розірвати", @@ -689,6 +976,21 @@ "ui.coincident": "Співпадання", "ui.collapse": "Згорнути", "ui.collinear": "Колінеарність", + "ui.colour_vision": "Кольоровий зір", + "ui.colour_vision_deutan": "Дейтеран", + "ui.colour_vision_deutan_tip": "Для слабкого або відсутнього сприйняття зеленого", + "ui.colour_vision_failed": "Невдала", + "ui.colour_vision_fixed": "Фіксована", + "ui.colour_vision_mode": "Режим", + "ui.colour_vision_note": "Змінює кольори, що несуть значення: стани ескізу, осі, згини, аналізи та значки. Якщо ви користуєтеся кольоровими фільтрами Windows, залиште це вимкненим.", + "ui.colour_vision_off": "Вимкнено", + "ui.colour_vision_off_tip": "Власні кольори теми", + "ui.colour_vision_projected": "Спроєктована", + "ui.colour_vision_protan": "Протанія", + "ui.colour_vision_protan_tip": "Для слабкого або відсутнього сприйняття червоного", + "ui.colour_vision_sample": "Зразок", + "ui.colour_vision_tritan": "Тритан", + "ui.colour_vision_tritan_tip": "Для слабкого або відсутнього сприйняття синього", "ui.combine": "Об'єднати", "ui.coming_later": "з'явиться пізніше", "ui.command_done": "{command} виконано", @@ -713,6 +1015,8 @@ "ui.curvature": "Кривизна", "ui.curvature_g2": "Кривизна (G2)", "ui.curves": "Криві", + "ui.custom_program": "Власна…", + "ui.custom_program_tip": "Виберіть програму-слайсер, щоб додати її до списку", "ui.customizeToolbar": "Налаштувати панель інструментів…", "ui.cut": "Вирізати", "ui.cylinder": "Циліндр", @@ -722,9 +1026,25 @@ "ui.delete": "Видалити", "ui.depth": "Глибина", "ui.design": "Проєкт", + "ui.design_properties.author": "Автор", + "ui.design_properties.description": "Опис", + "ui.design_properties.exports": "Експорт STEP, IGES і 3MF переносить їх туди, де формат має для них місце.", + "ui.design_properties.license": "Ліцензія", + "ui.design_properties.not_text": "Збережено не як текст; введення тут замінить його.", + "ui.design_properties.organization": "Організація", + "ui.design_properties.others": "Дані інших інструментів", + "ui.design_properties.others_note": "Зберігається в проєкті як є й ніколи не експортується.", + "ui.design_properties.part_number": "Номер деталі", + "ui.design_properties.project": "Проєкт", + "ui.design_properties.revision": "Редакція", "ui.diameter": "Діаметр", "ui.dimension": "Розмір", "ui.direction": "Напрямок", + "ui.display_quality": "Якість відображення", + "ui.display_quality_coarse": "Груба", + "ui.display_quality_fine": "Висока", + "ui.display_quality_normal": "Звичайна", + "ui.display_quality_ultra": "Ультра", "ui.distance": "Відстань", "ui.distance_1": "Відстань 1", "ui.distance_2": "Відстань 2", @@ -764,6 +1084,8 @@ "ui.fix_unfix": "Закріпити/розкріпити", "ui.flat": "Плоска", "ui.flip": "Перевернути", + "ui.format": "Формат", + "ui.frame_rate": "Частота кадрів", "ui.front": "Спереду", "ui.fully_constrained": "Повністю визначений", "ui.general": "Загальні", @@ -816,6 +1138,7 @@ "ui.heading_rectangular_pattern": "ПРЯМОКУТНИЙ МАСИВ", "ui.heading_revolve": "ОБЕРТАННЯ", "ui.heading_right": "СПРАВА", + "ui.heading_save_mesh": "ЗБЕРЕГТИ ЯК СІТКУ", "ui.heading_scale": "МАСШТАБ", "ui.heading_section_analysis": "АНАЛІЗ ПЕРЕРІЗУ", "ui.heading_select": "ВИБІР", @@ -890,6 +1213,9 @@ "ui.material_review.unknown": "Невідомо", "ui.material_review.yield_strength": "Межа плинності", "ui.measure": "Вимірювання", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF з мітками граней", + "ui.mesh_format_stl": "STL", "ui.midpoint": "Середня точка", "ui.mirror": "Дзеркало", "ui.mirror_line": "Вісь дзеркала", @@ -902,8 +1228,10 @@ "ui.newSketchGroup": "Нова група ескізів", "ui.new_body": "Нове тіло", "ui.new_design": "Новий проєкт", + "ui.no_slicer_found": "Слайсер не знайдено", "ui.normal": "Звичайна", "ui.normalConstruction": "Звичайна / конструкційна", + "ui.not_a_program": "{path} не є програмою, яку можна запустити", "ui.object_type": "Тип об'єкта", "ui.objects": "Об'єкти", "ui.offer_to_stand_up_y_up_files": "Пропонувати вирівнювати файли з напрямком Y вгору", @@ -949,12 +1277,14 @@ "ui.project": "Проєктувати", "ui.project_face_edges_sketch": "Проєктувати ребра граней у нові ескізи", "ui.properties": "Властивості", + "ui.pull_direction": "Напрямок витягування", "ui.quantity": "Кількість", "ui.quantity_2": "Кількість 2", "ui.quit": "Вихід", "ui.radial_context_menu": "Кругове контекстне меню", "ui.radius": "Радіус", "ui.radius_type": "Тип радіуса", + "ui.reachable": "Досяжні", "ui.rectangle": "Прямокутник", "ui.rectangular_pattern": "Прямокутний масив", "ui.redo": "Повторити", @@ -975,6 +1305,10 @@ "ui.save": "Зберегти", "ui.save_as": "Зберегти як", "ui.save_as_ellipsis": "Зберегти як...", + "ui.save_mesh_no_slicer": "Слайсер не знайдено: виберіть «Власна…» і вкажіть свою програму-слайсер або вимкніть «Надіслати в слайсер»", + "ui.save_mesh_pick_bodies": "Виберіть тіла для збереження", + "ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}", + "ui.saved_to": "Збережено {path}", "ui.scale": "Масштаб", "ui.section_analysis": "Аналіз перерізу", "ui.select": "Вибрати", @@ -982,6 +1316,9 @@ "ui.selectTangentChain": "Вибрати дотичний ланцюг", "ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.", "ui.selection": "Виділення", + "ui.send": "Надіслати", + "ui.send_to_slicer": "Надіслати в слайсер", + "ui.sent_to_slicer": "Надіслано в {slicer}", "ui.shell": "Оболонка", "ui.show": "Показати", "ui.showAll": "Показати все", @@ -1006,6 +1343,8 @@ "ui.sketch_status": "Стан ескізу", "ui.sketches": "Ескізи", "ui.slice": "Розріз", + "ui.slicer": "Слайсер", + "ui.slicer_delete_tip": "{program}\nНатисніть Delete, щоб прибрати його зі списку", "ui.slot": "Паз", "ui.smooth": "Плавний", "ui.smooth_wheel_zoom": "Плавне масштабування колесом", @@ -1021,6 +1360,9 @@ "ui.stand_up_tip": "Після відкриття файлу STEP або IGES з напрямком Z вгору пропонувати поворот на чверть оберту навколо X", "ui.start": "Початок", "ui.start_radius": "Початковий радіус", + "ui.supersampling": "Наддискретизація (SSAA)", + "ui.supersampling_adaptive": "Лише коли вид нерухомий", + "ui.supersampling_off": "Вимкнено (1×)", "ui.suppress": "Придушити", "ui.sweep": "Протягування", "ui.sweep_follow_direction": "Слідувати напрямку", @@ -1041,6 +1383,7 @@ "ui.timeline_left_sidebar": "Ліва бічна панель", "ui.tip_angle": "Кут вістря", "ui.to_object": "До об'єкта", + "ui.tool_direction": "Напрямок інструмента", "ui.top": "Зверху", "ui.torus": "Тор", "ui.total_angle": "Загальний кут", From 650ba567e7f38a427d6b64872abc37fb25161158 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:38:44 +0000 Subject: [PATCH 010/149] Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] --- docs/features.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features.md b/docs/features.md index 996ef0b0..fc4add7d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **383 commands.** Planned (registered, not built yet): 2. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **384 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (103) @@ -307,7 +307,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Add Recorded Feature | `timeline.add` | – | | Export Command Script | `timeline.export_script` | – | -## Inspect (13, 1 planned) +## Inspect (14) | Command | Id | Toolbar | |---|---|---| @@ -320,7 +320,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Environment Map Analysis | `inspect.environment_map` | SKETCH › INSPECT | | Accessibility Analysis | `inspect.accessibility` | SKETCH › INSPECT | | Curvature Map Analysis | `inspect.curvature_map` | SKETCH › INSPECT | -| Continuity Analysis *(planned: [#770](https://github.com/bherbruck/solidcraft/issues/770))* | `inspect.continuity` | SURFACE › INSPECT | +| Continuity Analysis | `inspect.continuity` | SURFACE › INSPECT | | Interference | `inspect.interference` | SOLID › INSPECT | | Measure (I) | `inspect.measure` | SOLID › INSPECT | | Compare Bodies | `inspect.compare` | – | From 04a0283082e0a543fdddc8b6fefbafff75b70080 Mon Sep 17 00:00:00 2001 From: jawler <84626502+jawler@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:43:45 +0200 Subject: [PATCH 011/149] Add files via upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Spanish (Spain) Localization 🇪🇸 This contribution adds a Spanish (Spain) translation for SolidCraft. The translation is based on the English localization file (`en.json`) and uses terminology commonly found in professional CAD software such as Autodesk Fusion 360, SolidWorks, and FreeCAD. ### What's included * Spanish (Spain) localization file (`es.json`). * Translation of menus, commands, tools, settings, and interface messages. * CAD-specific terminology adapted for Spanish-speaking users in Spain. * Original JSON keys and dynamic placeholders preserved. ### Notes * The translation has not yet been tested within the SolidCraft application. * The Spanish language may need to be registered in the language selector. * Feedback and corrections from native Spanish speakers are welcome. I hope this contribution helps make SolidCraft more accessible to Spanish-speaking users. Thank you for developing and maintaining this open-source CAD project! --- assets/locales/es.json | 1457 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1457 insertions(+) create mode 100644 assets/locales/es.json diff --git a/assets/locales/es.json b/assets/locales/es.json new file mode 100644 index 00000000..e312d1f2 --- /dev/null +++ b/assets/locales/es.json @@ -0,0 +1,1457 @@ +{ + "0 turns it off": "0 lo desactiva", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Abre el borrador de GitHub de abajo y pega el informe copiado al portapapeles.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Abre el borrador de GitHub de abajo. El título y el informe se rellenarán automáticamente.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Adjunta la captura de pantalla: cópiala y pégala, o guarda y sube el PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Haz clic en «Submit new issue» en GitHub para enviar el informe.", + "A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa", + "AI": "AI", + "About SolidCraft": "Acerca de SolidCraft", + "Active": "Activo", + "Active rule": "Regla activa", + "Add": "Añadir", + "Add Key": "Añadir clave", + "Add Mid Radius": "Añadir radio intermedio", + "Add Row": "Añadir fila", + "Add condition": "Añadir condición", + "Alignment": "Alineación", + "All Sketch Points": "Todos los puntos del boceto", + "All open designs": "Todos los diseños abiertos", + "Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma", + "Also deletes": "También elimina", + "Angle 2": "Ángulo 2", + "Animate": "Animar", + "Appearance name": "Nombre de apariencia", + "Applied Radius": "Radio aplicado", + "Apply To": "Aplicar a", + "Apply to selection": "Aplicar a la selección", + "Arc length": "Longitud del arco", + "Area": "Área", + "Attachment": "Vinculación", + "BROWSER": "EXPLORADOR", + "Bend Line Position": "Posición de la línea de plegado", + "Bend Position": "Posición del pliegue", + "Bend Radius": "Radio de plegado", + "Bends": "Pliegues", + "Body": "Cuerpo", + "Boundary": "Contorno", + "Bounding box": "Caja delimitadora", + "CSV or JSON: updates existing parameters, adds new ones": "CSV o JSON: actualiza los parámetros existentes y añade otros nuevos", + "Capture All Positions": "Capturar todas las posiciones", + "Catch Direction": "Dirección de enganche", + "Cells": "Celdas", + "Center X": "Centro X", + "Center Y": "Centro Y", + "Center of mass": "Centro de masas", + "Centre to centre": "Entre centros", + "Chamfer Type": "Tipo de chaflán", + "Check collisions": "Comprobar colisiones", + "Checked": "Comprobado", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Elige un plano nuevo. La geometría y las cotas del boceto se mantienen en sus coordenadas bidimensionales.", + "Circumference": "Circunferencia", + "Close draft": "Cerrar borrador", + "Collisions": "Colisiones", + "Colour": "Color", + "Component1": "Componente1", + "Configuration": "Configuración", + "Configuration rules": "Reglas de configuración", + "Construction": "Construcción", + "Contact": "Contacto", + "Continuity": "Continuidad", + "Copy Error Message": "Copiar mensaje de error", + "Copy Warning Message": "Copiar mensaje de advertencia", + "Copy all licences": "Copiar todas las licencias", + "Copy diagnostic information": "Copiar información de diagnóstico", + "Copy report": "Copiar informe", + "Copy screenshot": "Copiar captura de pantalla", + "Corner Relief": "Alivio de esquina", + "Corner Relief Shape": "Forma del alivio de esquina", + "Counterbore depth": "Profundidad del avellanado cilíndrico", + "Countersink angle": "Ángulo de avellanado", + "Create Issue — review draft": "Crear incidencia — revisar borrador", + "Cube": "Cubo", + "Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)", + "Current perspective view": "Vista en perspectiva actual", + "Curvature (G2)": "Curvatura (G2)", + "Customize Toolbar": "Personalizar barra de herramientas", + "Delete all": "Eliminar todo", + "Delete rule": "Eliminar regla", + "Delete the row": "Eliminar la fila", + "Delete the set": "Eliminar el conjunto", + "Delete this parameter": "Eliminar este parámetro", + "Density": "Densidad", + "Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.", + "Discard": "Descartar", + "Do not export hidden edges": "No exportar aristas ocultas", + "Double-click to rename": "Doble clic para cambiar el nombre", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada", + "Drive Joint": "Accionar unión", + "Edges": "Aristas", + "Edit Profile Sketch": "Editar boceto del perfil", + "Enable rule": "Activar regla", + "Enabled": "Activado", + "Explode": "Explosionar", + "Export Current View as DXF": "Exportar vista actual como DXF", + "Export Current View as DXF…": "Exportar vista actual como DXF…", + "Export DXF…": "Exportar DXF…", + "Export hidden edges as dashed lines": "Exportar aristas ocultas como líneas discontinuas", + "Extents": "Extensión", + "Face": "Cara", + "Face attachment lost": "Se ha perdido la vinculación de la cara", + "Faces": "Caras", + "Faces / edges / vertices": "Caras / aristas / vértices", + "Favourite": "Favorito", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Los archivos de muchos programas CAD usan Y como eje vertical, por eso aquí se abren tumbados (con Z hacia arriba).", + "Filter": "Filtrar", + "Find in Browser": "Buscar en el explorador", + "Flat": "Plano", + "Flat size": "Tamaño del desarrollo", + "Follow camera (Look At sketches)": "Seguir cámara (orientar hacia los bocetos)", + "Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)", + "Font": "Fuente", + "Full Length": "Longitud total", + "Gap": "Separación", + "Grid": "Cuadrícula", + "Height": "Altura", + "Hidden edges": "Aristas ocultas", + "Holes": "Agujeros", + "Holes (edges on them)": "Agujeros (aristas que contienen)", + "Home view": "Vista de inicio", + "In This Design": "En este diseño", + "In place (surfaces)": "En su posición (superficies)", + "Inside": "Interior", + "Interferes": "Interfiere", + "Issue title": "Título de la incidencia", + "Joint Origin1": "Origen de unión1", + "Keep Tools": "Conservar herramientas", + "Later": "Más tarde", + "Leave it": "Dejar como está", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Vinculación antigua de cara. Volver a vincularla a una cara verificada crea una vinculación exacta.", + "Length": "Longitud", + "Length units": "Unidades de longitud", + "Letter Spacing": "Espaciado entre letras", + "Library": "Biblioteca", + "Line Spacing": "Interlineado", + "Loop": "Bucle", + "Margin": "Margen", + "Mass": "Masa", + "Material": "Material", + "Max": "Máx.", + "Mid Radius": "Radio intermedio", + "Middle double-click": "Doble clic central", + "Min": "Mín.", + "Mitre": "Inglete", + "Modeled": "Modelado", + "More toolbar panels": "Más paneles de la barra de herramientas", + "Motion": "Movimiento", + "Move Type": "Tipo de movimiento", + "Move earlier": "Mover antes", + "Move later": "Mover después", + "Name": "Nombre", + "No crash reports.": "No hay informes de fallos.", + "No matching appearances": "No hay apariencias coincidentes", + "No visible bodies to export.": "No hay cuerpos visibles para exportar.", + "None": "Ninguno", + "Not available yet": "Todavía no disponible", + "Notch": "Muesca", + "Opacity": "Opacidad", + "Open GitHub draft": "Abrir borrador de GitHub", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código abierto desarrollado en Rust. Licencia MIT O Apache-2.0.", + "Other side": "Lado opuesto", + "Outside": "Exterior", + "Overall length": "Longitud total", + "Parameter values": "Valores de parámetros", + "Pass through": "Pasante", + "Path": "Trayectoria", + "Per Edge": "Por arista", + "Pick the tool bodies": "Selecciona los cuerpos herramienta", + "Picked": "Seleccionado", + "Place at the origin instead": "Colocar en el origen", + "Playback": "Reproducción", + "Position": "Posición", + "Position (0 to 1)": "Posición (de 0 a 1)", + "Position (G0)": "Posición (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro", + "Preview Row": "Previsualizar fila", + "Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.", + "Profiles/Curves": "Perfiles/curvas", + "Projection": "Proyección", + "RECENT": "RECIENTES", + "RULED SURFACE": "SUPERFICIE REGLADA", + "Radius Fit": "Ajuste de radio", + "Ratio": "Relación", + "Reattach selected face": "Volver a vincular la cara seleccionada", + "Recover": "Recuperar", + "Recover unsaved design?": "¿Recuperar el diseño sin guardar?", + "Rectangular": "Rectangular", + "Reference face": "Cara de referencia", + "Remove Tools": "Eliminar herramientas", + "Remove from list": "Quitar de la lista", + "Remove override": "Quitar anulación", + "Remove private design details or paths before sharing.": "Elimina los detalles privados del diseño o las rutas antes de compartirlo.", + "Remove screenshot": "Quitar captura de pantalla", + "Remove the column": "Eliminar la columna", + "Remove the track": "Eliminar la pista", + "Remove this point": "Eliminar este punto", + "Report a bug — review a screenshot and open a GitHub draft": "Notificar un error — revisar una captura y abrir un borrador de GitHub", + "Reset Toolbar": "Restablecer barra de herramientas", + "Restore outer boundary": "Restaurar contorno exterior", + "Restore the inherited component or physical material appearance": "Restaurar la apariencia heredada del componente o del material físico", + "Result": "Resultado", + "Retake screenshot": "Repetir captura de pantalla", + "Reverse": "Invertir", + "Reverse Direction": "Invertir dirección", + "Revert": "Revertir", + "Revert All Positions": "Revertir todas las posiciones", + "Revert Position": "Revertir posición", + "Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.", + "Rim Edge": "Arista del borde", + "Roll History Marker Here": "Desplazar marcador del historial hasta aquí", + "Round": "Redondo", + "Rule": "Regla", + "Save Rule": "Guardar regla", + "Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.", + "Scale factor": "Factor de escala", + "Search": "Buscar", + "Search commands": "Buscar comandos", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).", + "Select bodies (a face picks its body)": "Selecciona cuerpos (al seleccionar una cara se selecciona su cuerpo)", + "Select bodies or faces instead": "Selecciona cuerpos o caras en su lugar", + "Selection filter": "Filtro de selección", + "Semantic faces share their appearance across instances.": "Las caras semánticas comparten su apariencia entre instancias.", + "Send your report on GitHub": "Enviar tu informe a GitHub", + "Shape": "Forma", + "Show Agent Cursor": "Mostrar cursor del agente", + "Show in folder": "Mostrar en carpeta", + "Show the pointer an AI agent moves (#35)": "Mostrar el puntero que mueve un agente de IA (#35)", + "Signed axes": "Ejes con signo", + "Size": "Tamaño", + "Sketch constraints": "Restricciones del boceto", + "Sketch curves": "Curvas del boceto", + "Sketch dimensions": "Cotas del boceto", + "Sketch geometry and profiles": "Geometría y perfiles del boceto", + "Sketch plane has no body-face attachment.": "El plano del boceto no está vinculado a ninguna cara del cuerpo.", + "Sketch points": "Puntos del boceto", + "Slot width": "Anchura de la ranura", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.", + "Speed": "Velocidad", + "Stand it up": "Poner en vertical", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poner en vertical: un cuarto de vuelta alrededor del eje X, como una única operación Mover que puedes deshacer.", + "Stand this part up?": "¿Poner esta pieza en vertical?", + "Step": "Paso", + "Steps": "Pasos", + "Style": "Estilo", + "Suppressed": "Suprimido", + "Swap Ends": "Intercambiar extremos", + "Tangent (G1)": "Tangencia (G1)", + "Tangent Chain": "Cadena tangente", + "Tear": "Desgarro", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "El boceto discontinuo sirve de guía del plano guardado. Selecciona una cara nueva para reparar la vinculación.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Falta la instancia de autor guardada. Selecciona una posición de autor válida antes de volver a vincular.", + "Then": "Entonces", + "These features will be deleted:": "Se eliminarán estas operaciones:", + "These features will fail:": "Estas operaciones fallarán:", + "Thickness Outside": "Espesor hacia fuera", + "Thin": "Delgado", + "Thin Extrude": "Extrusión delgada", + "This child is shared. Deleting it removes it from every copy of its parent.": "Este elemento secundario es compartido. Si lo eliminas, desaparecerá de todas las copias de su elemento principal.", + "This child is shared. Position changes apply to every copy of its parent.": "Este elemento secundario es compartido. Los cambios de posición se aplican a todas las copias de su elemento principal.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.", + "To Offset": "Hasta desfase", + "Tolerance": "Tolerancia", + "Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.", + "Type": "Tipo", + "UNTRIM": "ANULAR RECORTE", + "Updating the preview": "Actualizando vista previa", + "Use active author placement": "Usar posición de autor activa", + "Use fixed plane": "Usar plano fijo", + "Use saved fixed plane": "Usar plano fijo guardado", + "Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo", + "VIEW": "VISTA", + "Value": "Valor", + "Vertices": "Vértices", + "View navigator": "Navegador de vistas", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).", + "Volume": "Volumen", + "Wall": "Pared", + "Wall Location": "Posición de la pared", + "Wall Thickness": "Espesor de pared", + "When": "Cuando", + "Width": "Anchura", + "Will fail": "Fallará", + "angle, height": "ángulo, altura", + "appearance.dielectric_ior": "Índice de refracción dieléctrico", + "appearance.metallic": "Metalicidad", + "appearance.pbr_help": "Visualiza esta superficie en Renderizado o en un PNG del producto. Sin refracción ni transmisión.", + "appearance.pbr_opaque": "PBR opaco", + "appearance.roughness": "Rugosidad", + "appearance.surface_response": "Respuesta de la superficie", + "browser.body_name": "Cuerpo{number}", + "browser.component_name": "Componente{number}", + "browser.parameters": "Parámetros ({count})", + "browser.precision": "Precisión: {precision}", + "browser.sketch_name": "Boceto{number}", + "browser.units": "Unidades: {units}", + "cmd.appearance_assign": "Apariencia", + "cmd.appearance_library": "Biblioteca de apariencias", + "cmd.appearance_list": "Listar apariencias", + "cmd.body_rename": "Cambiar nombre del cuerpo", + "cmd.browser_group": "Nuevo grupo", + "cmd.browser_move": "Mover elementos del explorador", + "cmd.browser_order": "Ordenar elementos del explorador", + "cmd.browser_rename_group": "Cambiar nombre del grupo", + "cmd.browser_ungroup": "Desagrupar", + "cmd.browser_visibility": "Mostrar/ocultar", + "cmd.canvas_calibrate": "Calibrar lienzo", + "cmd.canvas_decal": "Calcomanía", + "cmd.canvas_delete": "Eliminar lienzo", + "cmd.canvas_edit": "Editar lienzo", + "cmd.canvas_insert": "Lienzo", + "cmd.component_activate": "Activar componente", + "cmd.component_break_link": "Romper vínculo", + "cmd.component_capture_position": "Capturar posición", + "cmd.component_create": "Nuevo componente", + "cmd.component_delete": "Eliminar componente", + "cmd.component_from_bodies": "Crear componentes a partir de cuerpos", + "cmd.component_insert_design": "Insertar diseño", + "cmd.component_links": "Listar vínculos", + "cmd.component_list": "Listar componentes", + "cmd.component_move_bodies": "Mover cuerpos al componente", + "cmd.component_move_sketches": "Mover bocetos al componente", + "cmd.component_paste_new": "Pegar nuevo", + "cmd.component_properties": "Propiedades físicas del componente", + "cmd.component_rename": "Cambiar nombre del componente", + "cmd.component_revert_position": "Revertir posición", + "cmd.component_unlock": "Desbloquear contenido", + "cmd.component_update_links": "Actualizar vínculos", + "cmd.config_activate": "Activar configuración", + "cmd.config_column": "Columna de configuración", + "cmd.config_configure": "Configurar", + "cmd.config_preview": "Previsualizar configuración", + "cmd.config_row": "Fila de configuración", + "cmd.config_rule": "Regla de configuración", + "cmd.config_rules": "Reglas de configuración", + "cmd.config_table": "Mostrar tabla de configuraciones", + "cmd.construct_axis_edge": "Eje por arista", + "cmd.construct_axis_normal_to_face": "Eje perpendicular a cara", + "cmd.construct_axis_through_cylinder": "Eje por cilindro/cono/toro", + "cmd.construct_axis_two_planes": "Eje por dos planos", + "cmd.construct_axis_two_points": "Eje por dos puntos", + "cmd.construct_plane_along_path": "Plano a lo largo de trayectoria", + "cmd.construct_plane_midplane": "Plano medio", + "cmd.construct_plane_perpendicular": "Plano perpendicular", + "cmd.construct_plane_tangent": "Plano tangente", + "cmd.construct_plane_three_points": "Plano por tres puntos", + "cmd.construct_plane_two_edges": "Plano por dos aristas", + "cmd.construct_point_along_path": "Punto en trayectoria", + "cmd.construct_point_center": "Punto en el centro de círculo/esfera/toro", + "cmd.construct_point_edge_plane": "Punto en arista y plano", + "cmd.construct_point_three_planes": "Punto por tres planos", + "cmd.construct_point_two_edges": "Punto por dos aristas", + "cmd.construct_point_vertex": "Punto en vértice", + "cmd.contact_check": "Comprobar contacto", + "cmd.contact_create": "Nuevo conjunto de contactos", + "cmd.contact_disable_all": "Desactivar contacto", + "cmd.contact_edit": "Editar conjunto de contactos", + "cmd.contact_enable_all": "Activar todos los contactos", + "cmd.contact_enable_sets": "Activar conjuntos de contactos", + "cmd.contact_list": "Listar conjuntos de contactos", + "cmd.diagnostics_crash_report": "Informe de fallo", + "cmd.diagnostics_crash_reports": "Informes de fallos", + "cmd.diagnostics_crash_reports_clear": "Eliminar informes de fallos", + "cmd.diagnostics_crash_test": "Prueba de fallo", + "cmd.doc_recover": "Recuperar diseño", + "cmd.doc_recovery_discard": "Descartar diseño recuperado", + "cmd.doc_recovery_list": "Diseños recuperables", + "cmd.document_inspect": "Inspeccionar diseño", + "cmd.document_metadata": "Propiedades del diseño", + "cmd.document_metadata_remove": "Eliminar propiedad del diseño", + "cmd.document_metadata_set": "Cambiar propiedades del diseño", + "cmd.document_units": "Cambiar unidades del diseño", + "cmd.edit_checkpoint": "Punto de control", + "cmd.edit_restore_checkpoint": "Restaurar punto de control", + "cmd.engine_commands": "Listar comandos", + "cmd.explode_create": "Vista explosionada", + "cmd.explode_delete": "Eliminar vista explosionada", + "cmd.explode_list": "Listar vistas explosionadas", + "cmd.explode_show": "Mostrar vista explosionada", + "cmd.expr_evaluate": "Evaluar expresión", + "cmd.face_delete": "Eliminar cara", + "cmd.file_export": "Exportar", + "cmd.file_insert_mesh": "Insertar malla", + "cmd.file_insert_step": "Insertar STEP", + "cmd.file_save_mesh": "Guardar como malla", + "cmd.file_slicers": "Laminadores instalados", + "cmd.inspect_accessibility": "Análisis de accesibilidad", + "cmd.inspect_center_of_mass": "Centro de masas", + "cmd.inspect_compare": "Comparar cuerpos", + "cmd.inspect_continuity": "Análisis de continuidad", + "cmd.inspect_curvature_comb": "Análisis de peine de curvatura", + "cmd.inspect_curvature_map": "Análisis de mapa de curvatura", + "cmd.inspect_draft": "Análisis de ángulos de salida", + "cmd.inspect_environment_map": "Análisis de mapa de entorno", + "cmd.inspect_interference": "Interferencia", + "cmd.inspect_isocurve": "Análisis de isocurvas", + "cmd.inspect_minimum_radius": "Análisis de radio mínimo", + "cmd.inspect_zebra": "Análisis de franjas cebra", + "cmd.joint_as_built": "Unión según posición actual", + "cmd.joint_create": "Unión", + "cmd.joint_delete": "Eliminar unión", + "cmd.joint_drive": "Accionar uniones", + "cmd.joint_edit": "Editar unión", + "cmd.joint_gear": "Engranaje", + "cmd.joint_limits": "Límites de unión", + "cmd.joint_list": "Listar uniones", + "cmd.joint_motion_link": "Vínculo de movimiento", + "cmd.joint_origin": "Origen de unión", + "cmd.joint_rack_pinion": "Piñón y cremallera", + "cmd.joint_reach": "Punto de alcance", + "cmd.joint_rigid_group": "Grupo rígido", + "cmd.joint_screw": "Tornillo", + "cmd.joint_solve": "Resolver uniones", + "cmd.library_apply": "Aplicar elemento de biblioteca local", + "cmd.library_export": "Exportar biblioteca de diseños", + "cmd.library_inspect": "Inspeccionar biblioteca local", + "cmd.material_assign": "Material físico", + "cmd.material_create": "Nuevo material físico", + "cmd.material_delete": "Eliminar material físico", + "cmd.material_edit": "Editar material físico", + "cmd.material_list": "Listar materiales", + "cmd.material_properties_directional": "Módulo elástico direccional", + "cmd.material_properties_validate": "Validar datos del material físico", + "cmd.mesh_convert": "Convertir malla", + "cmd.model_edges": "Listar aristas", + "cmd.model_faces": "Listar caras", + "cmd.model_threads": "Listar roscas", + "cmd.motion_delete": "Eliminar estudio de movimiento", + "cmd.motion_export": "Exportar estudio de movimiento", + "cmd.motion_list": "Listar estudios de movimiento", + "cmd.motion_play": "Reproducir estudio de movimiento", + "cmd.motion_study": "Estudio de movimiento", + "cmd.motion_trace": "Trayectoria de movimiento", + "cmd.motion_trace_keep": "Conservar trayectoria de movimiento", + "cmd.occurrence_copy": "Pegar (instancia)", + "cmd.occurrence_ground": "Fijar", + "cmd.occurrence_move": "Mover instancia", + "cmd.occurrence_properties": "Propiedades físicas de la instancia", + "cmd.parameters_add": "Añadir parámetro de usuario", + "cmd.parameters_change": "Cambiar parámetros", + "cmd.parameters_comment": "Comentar parámetro", + "cmd.parameters_complete": "Completar expresión", + "cmd.parameters_delete": "Eliminar parámetro", + "cmd.parameters_export": "Exportar parámetros", + "cmd.parameters_favorite": "Parámetro favorito", + "cmd.parameters_graph": "Gráfico de parámetros", + "cmd.parameters_import": "Importar parámetros", + "cmd.parameters_list": "Listar parámetros", + "cmd.parameters_move": "Mover parámetro", + "cmd.parameters_rename": "Cambiar nombre del parámetro", + "cmd.parameters_users": "Usos del parámetro", + "cmd.parts_fastener": "Insertar elemento de fijación", + "cmd.parts_insert": "Insertar pieza", + "cmd.parts_library": "Piezas normalizadas", + "cmd.parts_resize": "Cambiar tamaño de pieza", + "cmd.plastic_assign_rule": "Asignar regla de plástico", + "cmd.plastic_boss": "Resalte", + "cmd.plastic_lip": "Labio", + "cmd.plastic_manage_rules": "Gestionar reglas de plástico", + "cmd.plastic_rest": "Apoyo", + "cmd.plastic_rules": "Listar reglas de plástico", + "cmd.plastic_snap_fit": "Encaje a presión", + "cmd.render_backends": "Motores de renderizado", + "cmd.render_cancel": "Cancelar renderizado", + "cmd.render_export": "Exportar imagen renderizada", + "cmd.render_preflight": "Comprobar solicitud de renderizado", + "cmd.render_scene": "Inspeccionar escena de renderizado", + "cmd.render_settings_apply": "Aplicar cámara de renderizado", + "cmd.render_settings_get": "Ajustes de renderizado", + "cmd.render_settings_set": "Guardar ajustes de renderizado", + "cmd.render_start": "Renderizar imagen", + "cmd.render_status": "Progreso del renderizado", + "cmd.select_clear": "Borrar selección", + "cmd.sheet_convert": "Convertir a chapa", + "cmd.sheet_export_bend_table": "Exportar tabla de plegados", + "cmd.sheet_export_dxf": "Exportar desarrollo como DXF", + "cmd.sheet_flange": "Pestaña", + "cmd.sheet_flat_pattern": "Crear desarrollo", + "cmd.sheet_fold": "Plegar", + "cmd.sheet_hem": "Reborde", + "cmd.sheet_manage_rules": "Reglas de chapa", + "cmd.sheet_perforate": "Perforar", + "cmd.sheet_refold": "Volver a plegar", + "cmd.sheet_rules": "Listar reglas de chapa", + "cmd.sheet_unfold": "Desplegar", + "cmd.sketch_arc_center_point": "Arco de centro y punto", + "cmd.sketch_arc_tangent": "Arco tangente", + "cmd.sketch_arc_three_point": "Arco de 3 puntos", + "cmd.sketch_auto_constrain": "Restringir automáticamente", + "cmd.sketch_auto_constrain_from_datum": "Restringir automáticamente desde referencia", + "cmd.sketch_auto_project": "Proyectar automáticamente aristas sobre referencia", + "cmd.sketch_blend_curve": "Curva de transición", + "cmd.sketch_chain": "Cadena", + "cmd.sketch_chamfer_distance_angle": "Chaflán por distancia y ángulo", + "cmd.sketch_chamfer_equal_distance": "Chaflán de distancias iguales", + "cmd.sketch_chamfer_two_distance": "Chaflán por dos distancias", + "cmd.sketch_circle_center": "Círculo por centro y diámetro", + "cmd.sketch_circle_three_point": "Círculo de 3 puntos", + "cmd.sketch_circle_three_tangent": "Círculo de 3 tangencias", + "cmd.sketch_circle_two_point": "Círculo de 2 puntos", + "cmd.sketch_circle_two_tangent": "Círculo de 2 tangencias", + "cmd.sketch_constrainer": "Restricciones del boceto", + "cmd.sketch_constraint_fix": "Fijar/liberar", + "cmd.sketch_constraint_midpoint": "Punto medio", + "cmd.sketch_construction": "Normal/construcción", + "cmd.sketch_copy": "Copiar entidades del boceto", + "cmd.sketch_cut": "Cortar entidades del boceto", + "cmd.sketch_delete": "Eliminar entidades del boceto", + "cmd.sketch_dimension_text": "Mover texto de cota", + "cmd.sketch_drag": "Arrastrar geometría del boceto", + "cmd.sketch_drag_end": "Terminar arrastre de boceto", + "cmd.sketch_draw_dimensioned": "Dibujar geometría de boceto acotada", + "cmd.sketch_edit": "Editar boceto", + "cmd.sketch_edit_text": "Editar texto", + "cmd.sketch_export_dxf": "Guardar como DXF", + "cmd.sketch_finish_auto_constrain": "Finalizar con restricciones automáticas", + "cmd.sketch_fit_curves_to_section": "Ajustar curvas a sección de malla", + "cmd.sketch_glyphs": "Símbolos de restricción", + "cmd.sketch_include_3d": "Incluir geometría 3D", + "cmd.sketch_infinite": "Línea infinita", + "cmd.sketch_insert_dxf": "Insertar DXF", + "cmd.sketch_insert_svg": "Insertar SVG", + "cmd.sketch_inspect": "Inspeccionar boceto", + "cmd.sketch_intersection_curve": "Curva de intersección", + "cmd.sketch_isoparametric_curve": "Curva isoparamétrica", + "cmd.sketch_line3d": "Línea 3D", + "cmd.sketch_line_midpoint": "Línea por punto medio", + "cmd.sketch_move": "Mover/copiar entidades de boceto", + "cmd.sketch_move3d": "Mover punto 3D", + "cmd.sketch_move_point": "Arrastrar punto de boceto", + "cmd.sketch_offset_break": "Romper asociación de desfase", + "cmd.sketch_options": "Opciones de la paleta de bocetos", + "cmd.sketch_paste": "Pegar entidades del boceto", + "cmd.sketch_point3d": "Punto 3D", + "cmd.sketch_polygon_circumscribed": "Polígono circunscrito", + "cmd.sketch_polygon_edge": "Polígono por arista", + "cmd.sketch_polygon_inscribed": "Polígono inscrito", + "cmd.sketch_project_to_surface": "Proyectar sobre superficie", + "cmd.sketch_rectangle_center": "Rectángulo por centro", + "cmd.sketch_rectangle_rounded": "Rectángulo redondeado", + "cmd.sketch_rectangle_three_point": "Rectángulo de 3 puntos", + "cmd.sketch_rectangle_two_point": "Rectángulo de 2 puntos", + "cmd.sketch_redefine": "Redefinir plano del boceto", + "cmd.sketch_slot_arc_center": "Ranura de arco por centro", + "cmd.sketch_slot_arc_three_point": "Ranura de arco de tres puntos", + "cmd.sketch_slot_center_point": "Ranura por punto central", + "cmd.sketch_slot_center_to_center": "Ranura entre centros", + "cmd.sketch_slot_overall": "Ranura por longitud total", + "cmd.sketch_snap": "Detección de referencias", + "cmd.sketch_solve": "Resolver boceto", + "cmd.sketch_spline3d": "Spline 3D", + "cmd.sketch_spline_control_point": "Spline por puntos de control", + "cmd.sketch_spline_control_point_5": "Spline por puntos de control (grado 5)", + "cmd.sketch_spline_fit_point": "Spline por puntos de ajuste", + "cmd.sketch_spun_profile": "Perfil de revolución", + "cmd.sketch_toggle_driven": "Alternar cota de referencia", + "cmd.solid_align": "Alinear", + "cmd.solid_boundary_fill": "Relleno de contorno", + "cmd.solid_bounding_solid": "Sólido envolvente", + "cmd.solid_emboss": "Relieve", + "cmd.solid_move": "Mover/copiar", + "cmd.solid_offset_face": "Desfasar cara", + "cmd.solid_press_pull": "Pulsar/tirar", + "cmd.solid_remove": "Eliminar", + "cmd.solid_replace_face": "Sustituir cara", + "cmd.solid_rib": "Nervio", + "cmd.solid_split_face": "Dividir cara", + "cmd.solid_web": "Refuerzo", + "cmd.surface_blend": "Superficie de transición", + "cmd.surface_boundary": "Superficie de contorno", + "cmd.surface_extrude": "Extruir superficie", + "cmd.surface_fair": "Suavizar superficie", + "cmd.surface_fillet": "Redondeo de superficie", + "cmd.surface_loft": "Transición de superficies", + "cmd.surface_match": "Igualar superficie", + "cmd.surface_offset": "Desfasar superficie", + "cmd.surface_patch": "Parche", + "cmd.surface_reverse_normal": "Invertir normal", + "cmd.surface_revolve": "Revolución de superficie", + "cmd.surface_ruled": "Superficie reglada", + "cmd.surface_stitch": "Coser", + "cmd.surface_sweep": "Barrido de superficie", + "cmd.surface_thicken": "Engrosar", + "cmd.surface_unstitch": "Descoser", + "cmd.surface_untrim": "Anular recorte", + "cmd.timeline_add": "Añadir operación registrada", + "cmd.timeline_compute_all": "Calcular todo", + "cmd.timeline_copy": "Copiar operaciones", + "cmd.timeline_dependents": "Operaciones dependientes", + "cmd.timeline_export_script": "Exportar script de comandos", + "cmd.timeline_inspect": "Inspeccionar operación", + "cmd.timeline_paste": "Pegar operaciones", + "cmd.timeline_redefine": "Editar operación", + "cmd.timeline_rename": "Cambiar nombre", + "cmd.timeline_reorder": "Reordenar operación", + "cmd.timeline_roll_to": "Desplazar marcador del historial", + "cmd.timeline_rollback": "Mover marcador de la línea de tiempo", + "cmd.timeline_suppress": "Suprimir operación", + "cmd.view_apply": "Restaurar vista guardada", + "cmd.view_delete": "Eliminar vista guardada", + "cmd.view_list": "Listar vistas guardadas", + "cmd.view_navigate": "Navegar por la vista", + "cmd.view_rename": "Cambiar nombre de vista guardada", + "cmd.view_save": "Nueva vista guardada", + "command name": "nombre del comando", + "coupling.gear_1": "Engranaje 1", + "coupling.gear_1_pick": "Engranaje 1 (opcional)", + "coupling.gear_2": "Engranaje 2", + "coupling.gear_2_pick": "Engranaje 2 (opcional)", + "coupling.internal": "Engranaje interior", + "coupling.needs_turn_and_slide": "necesita una unión giratoria y otra deslizante", + "coupling.needs_turns": "necesita dos uniones giratorias", + "coupling.not_round": "no es una cara circular", + "coupling.nut": "Tuerca", + "coupling.picked": "seleccionado", + "coupling.pinion": "Piñón", + "coupling.pinion_pick": "Piñón (opcional)", + "coupling.pitch": "Paso", + "coupling.rack": "Cremallera", + "coupling.radius_1": "Radio 1", + "coupling.radius_2": "Radio 2", + "dialog.computing": "Calculando… {seconds} s", + "dialog.custom_axis": "↖ Eje personalizado", + "dialog.edit_title": "EDITAR {title}", + "dialog.select": "↖ Seleccionar", + "dialog.selected_count": "↖ {count} seleccionados", + "dialog.selection_guidance": "Selecciona una entrada para cambiarla. Haz clic en los objetos seleccionados para quitarlos; Ctrl/Cmd+clic también alterna su selección. Revisa la vista previa y pulsa Aceptar.", + "dialog.selection_step": "{step} de {total}: Selecciona {input}", + "dialog.step": "Paso", + "document.save_changes": "¿Guardar los cambios de {name}?", + "document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.", + "drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}", + "drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más", + "drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado", + "expression, e.g. 25 mm": "expresión, p. ej., 25 mm", + "fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8", + "from the rule": "de la regla", + "home.documentation": "Documentación", + "home.in_this_browser": "en este navegador", + "home.missing": "no disponible", + "home.open": "Abrir…", + "home.opened_days_ago": "Abierto hace {count} días", + "home.opened_hours_ago": "Abierto hace {count} h", + "home.opened_just_now": "Acaba de abrirse", + "home.opened_minutes_ago": "Abierto hace {count} min", + "home.opening": "abriendo…", + "home.recent": "Recientes", + "home.report_issue": "Notificar un problema", + "home.samples": "Ejemplos", + "home.whats_new": "Novedades", + "install.button_tip": "Instalar SolidCraft como aplicación: se abre en una ventana propia y funciona sin conexión", + "install.dock_1": "Elige Archivo › Añadir al Dock.", + "install.dock_2": "Haz clic en Añadir.", + "install.dock_after": "A partir de entonces, SolidCraft se abrirá desde el Dock en su propia ventana y funcionará sin conexión.", + "install.done": "Listo", + "install.home_screen_1": "Toca Compartir (en el menú … si no aparece en la barra de herramientas).", + "install.home_screen_2": "Elige Añadir a la pantalla de inicio.", + "install.home_screen_3": "Toca Añadir.", + "install.home_screen_after": "SolidCraft se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.", + "install.menu": "Instalar aplicación…", + "install.steps_intro": "Safari instala aplicaciones web desde sus propios menús:", + "install.title": "Instalar SolidCraft", + "joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.", + "joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.", + "joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.", + "nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.", + "nav.touchpad.label": "Panel táctil", + "nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)", + "nav.touchpad.pan": "Desplazar (mantén Mayús y mueve el puntero; sin pulsar botones)", + "nav.touchpad.scroll": "El panel táctil usa el desplazamiento vertical para hacer zoom. La opción de desplazamiento con dos dedos se conserva para otros ajustes predefinidos.", + "nav.touchpad.zoom": "Zoom (desplazamiento vertical o gesto de pellizco)", + "needs two joints": "necesita dos uniones", + "new configuration": "nueva configuración", + "new name": "nuevo nombre", + "new parameter": "nuevo parámetro", + "no interference": "sin interferencias", + "no joint that moves yet": "todavía no hay ninguna unión móvil", + "no picture": "sin imagen", + "pattern.alignment_2": "Alineación 2", + "pattern.alignment_hint": "Los recuentos incluyen el original. Centrar equilibra las copias; Invertir elige el lado adicional cuando el total es par.", + "pattern.direction_1": "Dirección 1", + "pattern.direction_2": "Dirección 2", + "pattern.flip_direction": "Invertir dirección", + "pick a hole to seat it, or OK places it at the origin": "selecciona un agujero para colocarlo o pulsa Aceptar para situarlo en el origen", + "pick one or two vertices, edges or faces": "selecciona uno o dos vértices, aristas o caras", + "render.background": "Fondo de la escena", + "render.fit": "Ajustar producto", + "render.image_cancel": "Cancelar renderizado", + "render.image_cancelled": "Renderizado cancelado", + "render.image_cancelling": "Cancelando", + "render.image_check": "Comprobar solicitud de renderizado", + "render.image_completed": "Imagen lista para exportar", + "render.image_encoding": "Codificando PNG", + "render.image_export": "Exportar PNG", + "render.image_failed": "Error de renderizado", + "render.image_height": "Altura", + "render.image_help": "Imagen de vista previa por CPU · PNG · transparencia aproximada", + "render.image_overwrite": "Sustituir archivo existente", + "render.image_preparing": "Preparando imagen", + "render.image_queued": "Esperando para renderizar", + "render.image_reduce": "Prueba con una imagen más pequeña o una escena más sencilla.", + "render.image_running": "Renderizando imagen", + "render.image_stale": "Esta imagen utiliza la escena capturada antes de los cambios posteriores.", + "render.image_start": "Iniciar renderizado", + "render.image_supported": "La solicitud de renderizado es compatible; la cola y la memoria disponible se comprobarán de nuevo al iniciar.", + "render.image_title": "Renderizar imagen", + "render.image_too_large": "Supera el límite de imagen: 4096 píxeles por lado y 4.194.304 píxeles en total.", + "render.image_transparent": "Fondo transparente", + "render.image_unknown": "Estado del renderizado no disponible", + "render.image_use_largest": "Usar el tamaño máximo para esta proporción:", + "render.image_width": "Anchura", + "render.navigation_help": "La cámara se mueve como en Diseño (Preferencias › Navegación). Guardar configuración registra la cámara actual.", + "render.opaque_image_summary": "PNG de vista previa · PBR opaco · transparencia combinada", + "render.opaque_preview_help": "PBR opaco y colores sencillos; transparencia combinada. La refracción, HDRI y el renderizado final aún no están disponibles.", + "render.perspective": "Cámara en perspectiva", + "render.preview": "Vista previa integrada", + "render.preview_help": "Sombreado por rasterización y transparencia combinada. La refracción, HDRI y el renderizado final aún no están disponibles.", + "render.reload_settings": "Recargar ajustes guardados", + "render.restore_camera": "Restaurar cámara guardada", + "render.return_design": "Volver a Diseño", + "render.save_setup": "Guardar configuración", + "render.setup_name": "Nombre de configuración", + "render.unavailable_preview": "Esta configuración solicita un perfil de vista previa no disponible.", + "render.unsaved": "Cambios de vista previa sin guardar", + "render.use_preview": "Usar vista previa integrada", + "sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm", + "sample.bracket.name": "Soporte de chapa", + "sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión", + "sample.enclosure.name": "Carcasa de plástico", + "sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro", + "sample.flange.name": "Brida atornillada", + "sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°", + "sample.hinge.name": "Conjunto de bisagra", + "sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado", + "sample.plate.name": "Placa de ejemplo", + "sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros", + "sample.vents.name": "Placa de ventilación", + "settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.", + "settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.", + "settings.measure_axis_distances": "Mostrar distancias X/Y/Z", + "settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.", + "settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft", + "settings.msaa_reset": "{setting}: este dispositivo gráfico no admite {requested}×; se ha establecido {reset}× (admitidos: {supported}).", + "settings.msaa_unsupported": "No compatible con este dispositivo gráfico", + "settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:", + "settings.reset_title": "Se han cambiado los ajustes", + "settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.", + "sketch.degrees_of_freedom": "{count} grados de libertad", + "sketch.draw_curves": "Dibujar como {linetype}", + "sketch.make_selection": "Convertir la selección a {linetype}", + "sketch_text.align_center": "Centrar", + "sketch_text.align_left": "Alinear a la izquierda", + "sketch_text.align_right": "Alinear a la derecha", + "sketch_text.bold": "Negrita", + "sketch_text.builtin_font": "{family} (integrada)", + "sketch_text.font_search": "Buscar fuentes", + "sketch_text.italic": "Cursiva", + "sketch_text.loading_fonts": "Buscando fuentes instaladas…", + "sketch_text.on_path": "El texto sigue su trayectoria: la curva determina su posición", + "sketch_text.path_clear": "Separar el texto de su curva", + "sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva", + "sketch_text.use_font_file": "Usar el archivo de fuente {path}", + "the parts library is not available": "la biblioteca de piezas no está disponible", + "timeline.group": "Nueva carpeta de operaciones", + "timeline.move_many": "Mover elementos de la línea de tiempo", + "timeline.move_to_group": "Mover operaciones a la carpeta", + "timeline.rename_group": "Cambiar nombre de carpeta de operaciones", + "timeline.reorder_many": "Reordenar operaciones", + "timeline.ungroup": "Quitar carpetas de operaciones", + "to the next face": "hasta la siguiente cara", + "toolbar.image": "IMAGEN", + "toolbar.render": "RENDERIZAR", + "toolbar.render_scene": "ESCENA", + "trace.points": "{count} puntos a lo largo de la trayectoria", + "trace.stopped_by_contact": "Termina en la muestra {sample}: {a} atravesaría {b}", + "trace.stopped_by_loop": "Termina en la muestra {sample}: el bucle que pasa por {joint} no puede cerrarse más", + "ui.1_degree_of_freedom": "1 grado de libertad", + "ui.3d_mouse_speed": "Velocidad del ratón 3D", + "ui.3d_mouse_windows": "Ratón 3D", + "ui.3d_sketch": "Boceto 3D", + "ui.about": "Acerca de", + "ui.activate": "Activar", + "ui.active_component": "Componente activo", + "ui.agent_chat": "Chat del agente", + "ui.agent_chat_allow": "Permitir", + "ui.agent_chat_allowed": "Permitido", + "ui.agent_chat_answered": "Respuesta: {option}", + "ui.agent_chat_approval": "¿Permitir que el agente haga esto?", + "ui.agent_chat_attach_view": "Adjuntar vista", + "ui.agent_chat_attach_view_tip": "Enviar una imagen de la vista actual con el mensaje", + "ui.agent_chat_auth_agent": "Iniciar sesión con {name}", + "ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia SolidCraft.", + "ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; SolidCraft nunca ve tu contraseña ni tus claves.", + "ui.agent_chat_auth_none": "El agente no ofrece aquí ningún método de inicio de sesión: sigue las instrucciones de su documentación.", + "ui.agent_chat_auth_retry": "Ya he iniciado sesión: volver a conectar", + "ui.agent_chat_auth_terminal": "Ejecuta esto en una terminal y vuelve a pulsar Conectar:", + "ui.agent_chat_auth_title": "Iniciar sesión en el agente", + "ui.agent_chat_cancel": "Cancelar", + "ui.agent_chat_changes": "Cambios: {n}, un paso para deshacer", + "ui.agent_chat_changes_of": "{n}/{limit} cambios", + "ui.agent_chat_close": "Cerrar", + "ui.agent_chat_command": "Comando del agente", + "ui.agent_chat_connect": "Conectar", + "ui.agent_chat_copy": "Copiar", + "ui.agent_chat_custom_hint": "Cualquier programa que utilice el protocolo Agent Client Protocol por entrada y salida estándar. No se ejecuta mediante un intérprete de comandos.", + "ui.agent_chat_denied": "Denegado", + "ui.agent_chat_deny": "Denegar", + "ui.agent_chat_desktop_only": "El chat del agente requiere la aplicación de escritorio.", + "ui.agent_chat_detecting": "Buscando agentes…", + "ui.agent_chat_dock_left": "Acoplar a la izquierda", + "ui.agent_chat_dock_right": "Acoplar a la derecha", + "ui.agent_chat_edit": "Editar", + "ui.agent_chat_exited": "El agente se ha detenido.", + "ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}", + "ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.", + "ui.agent_chat_key_in_env": "clave configurada", + "ui.agent_chat_left": "Izquierda", + "ui.agent_chat_limit": "El agente ha alcanzado el límite de {limit} cambios por mensaje. Envía otro mensaje para que continúe.", + "ui.agent_chat_minimize": "Minimizar a una franja", + "ui.agent_chat_model_tip": "Modelo que utiliza el agente. Se recuerda tu elección para este agente.", + "ui.agent_chat_new": "Nuevo chat", + "ui.agent_chat_new_confirm": "¿Iniciar un nuevo chat? Se borrará la conversación actual.", + "ui.agent_chat_new_yes": "Nuevo chat", + "ui.agent_chat_no_cli": "No se ha encontrado solidcraft-cli junto a la aplicación, por lo que el agente no puede acceder al diseño.", + "ui.agent_chat_no_control": "No se ha iniciado el punto de conexión del chat del agente, por lo que no puede acceder al diseño.", + "ui.agent_chat_no_images": "Este agente no admite imágenes", + "ui.agent_chat_none_found": "No se ha encontrado ningún agente de programación. Instala uno de estos y pulsa Actualizar:", + "ui.agent_chat_none_found_short": "No se ha encontrado ningún agente", + "ui.agent_chat_not_installed": "No instalado", + "ui.agent_chat_not_signed_in": "sesión no iniciada", + "ui.agent_chat_permission": "El agente solicita ejecutar: {title}", + "ui.agent_chat_pick": "Agente", + "ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…", + "ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)", + "ui.agent_chat_privacy": "Solo se envían datos al agente que ejecutes, con tu propia cuenta.", + "ui.agent_chat_privacy_note": "SolidCraft no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. SolidCraft nunca ve ni almacena claves o contraseñas.", + "ui.agent_chat_queue": "Poner en cola", + "ui.agent_chat_queue_full": "Hay demasiados mensajes en espera: elimina uno o espera a que termine el agente.", + "ui.agent_chat_queued": "En cola", + "ui.agent_chat_queued_stopped": "En cola (detenido, sin enviar)", + "ui.agent_chat_refresh": "Actualizar", + "ui.agent_chat_refused": "No permitido para el agente: {reason}", + "ui.agent_chat_remove": "Quitar de la cola", + "ui.agent_chat_right": "Derecha", + "ui.agent_chat_save": "Guardar", + "ui.agent_chat_send": "Enviar", + "ui.agent_chat_send_now": "Enviar ahora", + "ui.agent_chat_send_now_tip": "Detener el paso actual del agente y enviar esto a continuación (Ctrl+Intro)", + "ui.agent_chat_side": "Lado del chat del agente", + "ui.agent_chat_signed_in": "sesión iniciada", + "ui.agent_chat_state_auth": "Se requiere iniciar sesión", + "ui.agent_chat_state_busy": "Trabajando…", + "ui.agent_chat_state_exited": "Agente detenido", + "ui.agent_chat_state_off": "Sin conexión", + "ui.agent_chat_state_ready": "Listo", + "ui.agent_chat_state_starting": "Iniciando…", + "ui.agent_chat_stop": "Detener", + "ui.agent_chat_stop_tip": "Detener el agente y todo lo que haya iniciado", + "ui.agent_chat_stop_turn_tip": "Detener el agente (Esc). Los mensajes en cola permanecen en cola.", + "ui.agent_chat_turn_limit": "Cambios por mensaje", + "ui.agent_chat_undo_changes": "Deshacer estos cambios", + "ui.agent_chat_undo_label": "Chat del agente: {message}", + "ui.agent_chat_undone": "(deshecho)", + "ui.agent_chat_view_attached": "Vista adjunta", + "ui.agent_cursor": "Cursor del agente", + "ui.agent_cursor_speed": "Velocidad del cursor del agente", + "ui.ai_add": "Añadir", + "ui.ai_add_agent": "Añadir agente…", + "ui.ai_add_agent_title": "Añadir agente", + "ui.ai_add_api": "API compatible con OpenAI", + "ui.ai_add_custom": "Comando personalizado", + "ui.ai_add_installed": "Agente instalado", + "ui.ai_add_installed_note": "Una entrada personalizada para un agente instalado, por ejemplo, con otros argumentos. Los agentes instalados también aparecen por separado.", + "ui.ai_added": "añadido", + "ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de SolidCraft siguen los permisos de Preferencias > IA.", + "ui.ai_agents": "Agentes", + "ui.ai_always_allow": "Permitir siempre", + "ui.ai_always_allow_tip": "Permitir {tool} a este agente a partir de ahora, sin preguntar", + "ui.ai_always_allowed": "Permitido siempre", + "ui.ai_always_allowed_none": "Todavía no hay permisos permanentes. Aquí se muestran las respuestas «Permitir siempre» que hayas dado en el chat.", + "ui.ai_auto_allowed": "Permitido automáticamente: {title} ({why})", + "ui.ai_auto_mode": "tu configuración de permisos", + "ui.ai_auto_ours": "SolidCraft comprueba sus propias herramientas", + "ui.ai_auto_remembered": "elegiste Permitir siempre", + "ui.ai_bad_url": "Introduce una URL que empiece por http:// o https://", + "ui.ai_base_url": "URL base", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "copia", + "ui.ai_custom_url": "URL personalizada", + "ui.ai_default": "Predeterminado", + "ui.ai_http_busy": "el servicio está ocupado o ha superado su límite; inténtalo más tarde", + "ui.ai_http_key": "el servicio ha rechazado la clave: compruébala en Preferencias > IA", + "ui.ai_http_not_found": "no existe ese servicio o modelo: comprueba la URL base y el nombre del modelo", + "ui.ai_key": "Clave de API", + "ui.ai_key_hint": "opcional; se guarda en el llavero del sistema", + "ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.", + "ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de SolidCraft. Déjalo vacío para un servidor local.", + "ui.ai_keychain_error": "El llavero del sistema ha rechazado la operación: {error}", + "ui.ai_mode_all": "Permitir todo", + "ui.ai_mode_all_note": "No se pide permiso previamente. Los informes de fallos siguen estando restringidos y se mantienen los límites de carpetas.", + "ui.ai_mode_all_warning": "El agente puede guardar, sobrescribir, exportar y sustituir tu diseño sin preguntar. Utiliza esta opción solo con agentes de confianza y diseños guardados.", + "ui.ai_mode_ask": "Preguntar siempre", + "ui.ai_mode_ask_note": "Cada modificación del diseño y cada operación de archivo esperan a que pulses Permitir.", + "ui.ai_mode_edits": "Permitir automáticamente cambios del modelo", + "ui.ai_mode_edits_note": "Los comandos de modelado se ejecutan sin preguntar; guardar, abrir, exportar y otras acciones sobre archivos o destructivas siguen requiriendo permiso.", + "ui.ai_mode_global": "Según la configuración anterior", + "ui.ai_model": "Modelo", + "ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de SolidCraft. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.", + "ui.ai_name": "Nombre", + "ui.ai_need_command": "Introduce el comando que inicia el agente", + "ui.ai_need_model": "Introduce el nombre del modelo", + "ui.ai_no_answer": "El agente no ha respondido a tiempo", + "ui.ai_no_http": "Esta compilación no puede acceder a servicios API", + "ui.ai_no_keychain": "El llavero del sistema no está disponible, por lo que no se puede guardar una clave. Un servidor local sin clave seguirá funcionando.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Permisos", + "ui.ai_preset": "Preajuste", + "ui.ai_remove": "Eliminar", + "ui.ai_request_limit": "se ha detenido tras 24 solicitudes en un mensaje; envía otro para continuar", + "ui.ai_reset": "Restablecer", + "ui.ai_settings": "Ajustes de IA…", + "ui.ai_show_features": "Mostrar funciones de IA", + "ui.ai_show_features_note": "Chat del agente: panel en el que un agente de programación que ya utilices, o un servicio de modelos que elijas, trabaja sobre el diseño abierto. Desactivado: no hay botón, atajo, entrada de menú ni panel de IA, y no se busca ni inicia nada.", + "ui.ai_switch_tip": "Cambiar el agente de esta conversación", + "ui.ai_test": "Probar", + "ui.ai_test_acp_ok": "Responde: {agent}", + "ui.ai_test_acp_tip": "Inicia brevemente el agente, le solicita una sesión y después lo detiene", + "ui.ai_test_api_tip": "Solicita la lista de modelos al servicio", + "ui.ai_test_no_model": "Conectado: {count} modelos, pero no {model}", + "ui.ai_test_ok": "Conectado: {count} modelos", + "ui.ai_testing": "Probando…", + "ui.ai_tool_done": "finalizado", + "ui.ai_tool_failed": "fallido", + "ui.ai_tool_running": "en ejecución", + "ui.ai_tool_unfold": "Haz clic para ver el resultado", + "ui.ai_tool_waiting": "en espera", + "ui.ai_toolbar_button": "Mostrar botón en la barra de herramientas", + "ui.ai_toolbar_button_note": "El botón IA situado al extremo derecho de la barra. Ctrl+Mayús+A y el menú Ver también abren el panel.", + "ui.ai_undo_tip": "Deshacer todos los cambios del agente en este mensaje, en un solo paso", + "ui.all": "Todo", + "ui.analysis": "Análisis", + "ui.angle": "Ángulo", + "ui.angle_decimals": "Decimales de ángulo", + "ui.animateJoint": "Animar unión", + "ui.animate_view_changes": "Animar cambios de vista", + "ui.animation": "Animación", + "ui.animation_speed": "Velocidad de animación", + "ui.anti_aliasing": "Suavizado de bordes", + "ui.apply": "Aplicar", + "ui.arc": "Arco", + "ui.assemble": "Ensamblar", + "ui.autosave_every": "Guardar automáticamente cada", + "ui.axes": "Ejes", + "ui.axis": "Eje", + "ui.base_point": "Punto base", + "ui.blocked": "Bloqueado", + "ui.bodies": "Cuerpos", + "ui.box": "Caja", + "ui.break": "Romper", + "ui.calibrate": "Calibrar", + "ui.cancel": "Cancelar", + "ui.canvases": "Lienzos", + "ui.center": "Centro", + "ui.centerline": "Línea de centro", + "ui.chain": "Cadena", + "ui.chamfer": "Chaflán", + "ui.chamfer_type": "Tipo de chaflán", + "ui.change_workspace": "Cambiar espacio de trabajo", + "ui.chord_length": "Longitud de cuerda", + "ui.circle": "Círculo", + "ui.circular_pattern": "Patrón circular", + "ui.clear": "Borrar", + "ui.clear_recent": "Borrar recientes", + "ui.clear_the_selection": "Borrar la selección", + "ui.click_selects_whole_bodies": "Un clic selecciona cuerpos completos", + "ui.click_then_press_key": "Haz clic y pulsa la nueva tecla", + "ui.close": "Cerrar", + "ui.coil": "Hélice", + "ui.coincident": "Coincidente", + "ui.collapse": "Contraer", + "ui.collinear": "Colineal", + "ui.colour_vision": "Visión del color", + "ui.colour_vision_deutan": "Deuteranopía", + "ui.colour_vision_deutan_tip": "Para personas con sensibilidad reducida o ausente al verde", + "ui.colour_vision_failed": "Fallido", + "ui.colour_vision_fixed": "Fijo", + "ui.colour_vision_mode": "Modo", + "ui.colour_vision_note": "Cambia los colores que transmiten información: estados de bocetos, ejes, pliegues, análisis e iconos. Si usas filtros de color de Windows, mantenlo desactivado.", + "ui.colour_vision_off": "Desactivado", + "ui.colour_vision_off_tip": "Colores propios del tema", + "ui.colour_vision_projected": "Proyectado", + "ui.colour_vision_protan": "Protanopía", + "ui.colour_vision_protan_tip": "Para personas con sensibilidad reducida o ausente al rojo", + "ui.colour_vision_sample": "Muestra", + "ui.colour_vision_tritan": "Tritanopía", + "ui.colour_vision_tritan_tip": "Para personas con sensibilidad reducida o ausente al azul", + "ui.combine": "Combinar", + "ui.coming_later": "próximamente", + "ui.command_done": "{command} completado", + "ui.command_palette_hint": "Escribe un comando, p. ej., {example}, o pulsa S para buscar", + "ui.concentric": "Concéntrico", + "ui.configurations": "Configuraciones", + "ui.conic_curve": "Curva cónica", + "ui.constant": "Constante", + "ui.constraints_conflict": "Conflicto entre restricciones", + "ui.construct": "Construir", + "ui.construction": "Construcción", + "ui.context_menu": "Menú contextual", + "ui.copy": "Copiar", + "ui.corner": "Esquina", + "ui.corners": "Esquinas", + "ui.counterbore": "Avellanado cilíndrico", + "ui.countersink": "Avellanado cónico", + "ui.create": "Crear", + "ui.create_copy": "Crear copia", + "ui.create_sketch": "Crear boceto", + "ui.current": "actual", + "ui.curvature": "Curvatura", + "ui.curvature_g2": "Curvatura (G2)", + "ui.curves": "Curvas", + "ui.custom_program": "Personalizado…", + "ui.custom_program_tip": "Elige un programa laminador para añadirlo a la lista", + "ui.customizeToolbar": "Personalizar barra de herramientas…", + "ui.cut": "Cortar", + "ui.cylinder": "Cilindro", + "ui.dark": "Oscuro", + "ui.default_modelling_orientation": "Orientación de modelado predeterminada", + "ui.degrees_of_freedom": "grados de libertad", + "ui.delete": "Eliminar", + "ui.depth": "Profundidad", + "ui.design": "Diseño", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Descripción", + "ui.design_properties.exports": "Las exportaciones STEP, IGES y 3MF incluyen estos datos cuando el formato lo permite.", + "ui.design_properties.license": "Licencia", + "ui.design_properties.not_text": "Está guardado en un formato distinto del texto; escribir aquí lo sustituirá.", + "ui.design_properties.organization": "Organización", + "ui.design_properties.others": "Datos de otras herramientas", + "ui.design_properties.others_note": "Se conservan en el diseño tal cual y nunca se exportan.", + "ui.design_properties.part_number": "Número de pieza", + "ui.design_properties.project": "Proyecto", + "ui.design_properties.revision": "Revisión", + "ui.diameter": "Diámetro", + "ui.dimension": "Cota", + "ui.direction": "Dirección", + "ui.display_quality": "Calidad de visualización", + "ui.display_quality_coarse": "Baja", + "ui.display_quality_fine": "Alta", + "ui.display_quality_normal": "Normal", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Distancia", + "ui.distance_1": "Distancia 1", + "ui.distance_2": "Distancia 2", + "ui.document_settings": "Configuración del documento", + "ui.don_t_save": "No guardar", + "ui.draft": "Ángulo de salida", + "ui.drawing": "Dibujo", + "ui.drill_point": "Punta de broca", + "ui.edit": "Editar", + "ui.editJointLimits": "Editar límites de unión", + "ui.edit_section_analysis": "Editar análisis de sección", + "ui.ellipse": "Elipse", + "ui.end_radius": "Radio final", + "ui.end_scale": "Escala final", + "ui.entities": "Entidades", + "ui.equal": "Igual", + "ui.equivalent_radius": "Radio equivalente", + "ui.expand": "Expandir", + "ui.exportDxf": "Exportar DXF…", + "ui.export_ellipsis": "Exportar...", + "ui.export_ellipsis_keymap": "Exportar…", + "ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo", + "ui.extend": "Extender", + "ui.extent_type": "Tipo de extensión", + "ui.extrude": "Extruir", + "ui.factor": "Factor", + "ui.feature_history": "Historial de operaciones", + "ui.feature_options": "Opciones de operación", + "ui.features": "Operaciones", + "ui.file": "Archivo", + "ui.fillet": "Redondeo", + "ui.filter_commands_or_keys": "Filtrar comandos o teclas", + "ui.findInTimeline": "Buscar en la línea de tiempo", + "ui.findInWindow": "Buscar en la ventana", + "ui.finish_sketch": "Finalizar boceto", + "ui.fit": "Ajustar", + "ui.fix_unfix": "Fijar/liberar", + "ui.flat": "Plano", + "ui.flip": "Invertir", + "ui.format": "Formato", + "ui.frame_rate": "Frecuencia de fotogramas", + "ui.front": "Frontal", + "ui.fully_constrained": "Totalmente restringido", + "ui.general": "General", + "ui.graphics": "Gráficos", + "ui.ground_shadow": "Sombra en el suelo", + "ui.groupSelected": "Agrupar selección", + "ui.heading_add_ins": "COMPLEMENTOS", + "ui.heading_assemble": "ENSAMBLAR", + "ui.heading_box": "CAJA", + "ui.heading_chamfer": "CHAFLÁN", + "ui.heading_circular_pattern": "PATRÓN CIRCULAR", + "ui.heading_coil": "HÉLICE", + "ui.heading_combine": "COMBINAR", + "ui.heading_configure": "CONFIGURAR", + "ui.heading_constraints": "RESTRICCIONES", + "ui.heading_construct": "CONSTRUIR", + "ui.heading_create": "CREAR", + "ui.heading_create_sketch": "CREAR BOCETO", + "ui.heading_cylinder": "CILINDRO", + "ui.heading_design": "DISEÑO", + "ui.heading_draft": "ÁNGULO DE SALIDA", + "ui.heading_edit": "EDITAR", + "ui.heading_extrude": "EXTRUIR", + "ui.heading_fillet": "REDONDEO", + "ui.heading_finish_sketch": "FINALIZAR BOCETO", + "ui.heading_front": "FRONTAL", + "ui.heading_hole": "AGUJERO", + "ui.heading_home": "INICIO", + "ui.heading_insert": "INSERTAR", + "ui.heading_inspect": "INSPECCIONAR", + "ui.heading_loft": "TRANSICIÓN", + "ui.heading_make": "FABRICAR", + "ui.heading_measure": "MEDIR", + "ui.heading_mesh": "MALLA", + "ui.heading_mirror": "SIMETRÍA", + "ui.heading_modify": "MODIFICAR", + "ui.heading_move": "MOVER", + "ui.heading_move_copy": "MOVER/COPIAR", + "ui.heading_offset": "DESFASE", + "ui.heading_offset_plane": "PLANO DESFASADO", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARÁMETROS", + "ui.heading_pattern": "PATRÓN", + "ui.heading_pattern_on_path": "PATRÓN EN TRAYECTORIA", + "ui.heading_pipe": "TUBO", + "ui.heading_plane_at_angle": "PLANO EN ÁNGULO", + "ui.heading_plastic": "PLÁSTICO", + "ui.heading_position": "POSICIÓN", + "ui.heading_prepare": "PREPARAR", + "ui.heading_rectangular_pattern": "PATRÓN RECTANGULAR", + "ui.heading_revolve": "REVOLUCIÓN", + "ui.heading_right": "DERECHA", + "ui.heading_save_mesh": "GUARDAR COMO MALLA", + "ui.heading_scale": "ESCALA", + "ui.heading_section_analysis": "ANÁLISIS DE SECCIÓN", + "ui.heading_select": "SELECCIONAR", + "ui.heading_setup": "CONFIGURACIÓN", + "ui.heading_sheet_metal": "CHAPA", + "ui.heading_shell": "VACIADO", + "ui.heading_sketch": "BOCETO", + "ui.heading_sketch_palette": "PALETA DE BOCETOS", + "ui.heading_sketch_scale": "ESCALA DEL BOCETO", + "ui.heading_solid": "SÓLIDO", + "ui.heading_sphere": "ESFERA", + "ui.heading_split_body": "DIVIDIR CUERPO", + "ui.heading_surface": "SUPERFICIE", + "ui.heading_sweep": "BARRIDO", + "ui.heading_thread": "ROSCA", + "ui.heading_top": "SUPERIOR", + "ui.heading_torus": "TORO", + "ui.heading_utilities": "UTILIDADES", + "ui.heading_utility": "UTILIDAD", + "ui.help": "Ayuda", + "ui.hide": "Ocultar", + "ui.hideAxes": "Ocultar ejes", + "ui.hideDimension": "Ocultar cota", + "ui.hideOrigin": "Ocultar origen", + "ui.hidePlanes": "Ocultar planos", + "ui.hideProfile": "Ocultar perfil", + "ui.hole": "Agujero", + "ui.hole_tap_type": "Tipo de roscado del agujero", + "ui.hole_type": "Tipo de agujero", + "ui.home": "Inicio", + "ui.homeView": "Vista de inicio", + "ui.horizontal_vertical": "Horizontal/vertical", + "ui.import_ellipsis": "Importar…", + "ui.infinite": "Infinito", + "ui.insert": "Insertar", + "ui.inspect": "Inspeccionar", + "ui.intersect": "Intersecar", + "ui.isolate": "Aislar", + "ui.join": "Unir", + "ui.joints": "Uniones", + "ui.keyboard_shortcuts": "Atajos de teclado", + "ui.language": "Idioma", + "ui.length_decimals": "Decimales de longitud", + "ui.light": "Claro", + "ui.line": "Línea", + "ui.linetype": "Tipo de línea", + "ui.lock": "Bloquear", + "ui.loft": "Transición", + "ui.look_at": "Mirar hacia", + "ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar", + "ui.manufacture": "Fabricación", + "ui.material_review.axes": "Ejes locales del material; no se ha asignado ninguna orientación al cuerpo.", + "ui.material_review.claims": "Las fuentes y licencias son declaraciones sin verificar. Los valores ausentes siguen siendo desconocidos.", + "ui.material_review.compressive_strength": "Resistencia a compresión", + "ui.material_review.conditions": "Condiciones declaradas", + "ui.material_review.density": "Densidad", + "ui.material_review.derived": "Módulos isotrópicos derivados", + "ui.material_review.elasticity": "Elasticidad", + "ui.material_review.input": "Pega una ficha de material de esquema 1 (JSON)", + "ui.material_review.limit": "La ficha supera el límite de entrada de 256 KiB.", + "ui.material_review.moisture_unit": "kg de agua / kg de material seco", + "ui.material_review.properties": "Propiedades", + "ui.material_review.review": "Revisar ficha", + "ui.material_review.scope": "Solo candidato: no se asigna ni se guarda. La validación numérica no equivale a una certificación de ingeniería.", + "ui.material_review.shear_strength": "Resistencia a cortante", + "ui.material_review.source": "Fuente", + "ui.material_review.specific_heat": "Calor específico", + "ui.material_review.tensile_strength": "Resistencia a tracción", + "ui.material_review.thermal_conductivity": "Conductividad térmica", + "ui.material_review.thermal_expansion": "Dilatación térmica", + "ui.material_review.title": "Revisar datos del material físico", + "ui.material_review.unknown": "Desconocido", + "ui.material_review.yield_strength": "Límite elástico", + "ui.measure": "Medir", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF con etiquetas de cara", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Punto medio", + "ui.mirror": "Simetría", + "ui.mirror_line": "Línea de simetría", + "ui.modify": "Modificar", + "ui.move": "Mover", + "ui.multiple": "Múltiple", + "ui.named_views": "Vistas guardadas", + "ui.navigation": "Navegación", + "ui.needs_design": "{command}: abre primero un diseño", + "ui.newSketchGroup": "Nuevo grupo de bocetos", + "ui.new_body": "Nuevo cuerpo", + "ui.new_design": "Nuevo diseño", + "ui.no_slicer_found": "No se ha encontrado ningún laminador", + "ui.normal": "Normal", + "ui.normalConstruction": "Normal / construcción", + "ui.not_a_program": "{path} no es un programa que se pueda ejecutar", + "ui.object_type": "Tipo de objeto", + "ui.objects": "Objetos", + "ui.offer_to_stand_up_y_up_files": "Ofrecer poner en vertical los archivos con eje Y hacia arriba", + "ui.offset": "Desfase", + "ui.offset_plane": "Plano desfasado", + "ui.one_side": "Un lado", + "ui.open": "Abrir", + "ui.open_ellipsis": "Abrir...", + "ui.open_preferences": "Abrir preferencias", + "ui.open_recent": "Abrir reciente", + "ui.operation": "Operación", + "ui.options": "Opciones", + "ui.orbit": "Orbitar", + "ui.orbit_around": "Orbitar alrededor de", + "ui.orientation": "Orientación", + "ui.origin": "Origen", + "ui.palette_points": "Puntos", + "ui.palette_profile": "Perfil", + "ui.pan": "Desplazar", + "ui.pan_zoom_orbit": "Desplazar, zoom y orbitar", + "ui.parallel": "Paralelo", + "ui.parameters": "Parámetros", + "ui.paste": "Pegar", + "ui.path": "Trayectoria", + "ui.pattern_on_path": "Patrón en trayectoria", + "ui.perpendicular": "Perpendicular", + "ui.perspective_view": "Vista en perspectiva", + "ui.pinToShortcuts": "Fijar en accesos directos", + "ui.pinToToolbar": "Fijar en barra de herramientas", + "ui.pipe": "Tubo", + "ui.placement": "Colocación", + "ui.plane": "Plano", + "ui.plane_at_angle": "Plano en ángulo", + "ui.point": "Punto", + "ui.point_under_the_cursor": "Punto bajo el cursor", + "ui.points": "Puntos", + "ui.polygon": "Polígono", + "ui.preferences": "Preferencias", + "ui.press_a_key": "pulsa una tecla…", + "ui.profile": "Perfil", + "ui.profile_plane": "Plano de perfil", + "ui.profiles": "Perfiles", + "ui.project": "Proyecto", + "ui.project_face_edges_sketch": "Proyectar aristas de caras en bocetos nuevos", + "ui.properties": "Propiedades", + "ui.pull_direction": "Dirección de tracción", + "ui.quantity": "Cantidad", + "ui.quantity_2": "Cantidad 2", + "ui.quit": "Salir", + "ui.radial_context_menu": "Menú contextual radial", + "ui.radius": "Radio", + "ui.radius_type": "Tipo de radio", + "ui.reachable": "Alcanzable", + "ui.rectangle": "Rectángulo", + "ui.rectangular_pattern": "Patrón rectangular", + "ui.redo": "Rehacer", + "ui.removeFromToolbar": "Quitar de la barra de herramientas", + "ui.removeSection": "Quitar sección", + "ui.render": "Renderizar", + "ui.renderer": "Motor de renderizado", + "ui.repeat": "Repetir", + "ui.report_a_bug": "Notificar un error", + "ui.reset": "Restablecer", + "ui.reset_all": "Restablecer todo", + "ui.reset_the_settings_on_this_page": "Restablecer los ajustes de esta página", + "ui.restore": "Restaurar", + "ui.restore_defaults": "Restaurar valores predeterminados", + "ui.reverse_zoom_direction": "Invertir dirección del zoom", + "ui.revolve": "Revolución", + "ui.right": "Derecha", + "ui.save": "Guardar", + "ui.save_as": "Guardar como", + "ui.save_as_ellipsis": "Guardar como...", + "ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador", + "ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar", + "ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}", + "ui.saved_to": "Guardado en {path}", + "ui.scale": "Escala", + "ui.section_analysis": "Análisis de sección", + "ui.select": "Seleccionar", + "ui.selectOther": "Seleccionar otro…", + "ui.selectTangentChain": "Seleccionar cadena tangente", + "ui.select_other_hint": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todos los elementos bajo el cursor; vuelve a pulsar en el mismo punto para seleccionar el siguiente.", + "ui.selection": "Selección", + "ui.send": "Enviar", + "ui.send_to_slicer": "Enviar al laminador", + "ui.sent_to_slicer": "Enviado a {slicer}", + "ui.shell": "Vaciado", + "ui.show": "Mostrar", + "ui.showAll": "Mostrar todo", + "ui.showAllBodies": "Mostrar todos los cuerpos", + "ui.showAxes": "Mostrar ejes", + "ui.showDimension": "Mostrar cota", + "ui.showOrigin": "Mostrar origen", + "ui.showPlanes": "Mostrar planos", + "ui.show_constraints": "Restricciones", + "ui.show_construction_geometries": "Geometrías de construcción", + "ui.show_dimensions": "Cotas", + "ui.show_points": "Mostrar puntos", + "ui.show_profile": "Mostrar perfil", + "ui.show_projected_geometries": "Geometrías proyectadas", + "ui.simple": "Sencillo", + "ui.simulation": "Simulación", + "ui.single": "Único", + "ui.sketch": "Boceto", + "ui.sketch_dimension": "Cota de boceto", + "ui.sketch_grid": "Cuadrícula de boceto", + "ui.sketch_scale": "Escala del boceto", + "ui.sketch_status": "Estado del boceto", + "ui.sketches": "Bocetos", + "ui.slice": "Laminar", + "ui.slicer": "Laminador", + "ui.slicer_delete_tip": "{program}\nPulsa Supr para quitarlo de la lista", + "ui.slot": "Ranura", + "ui.smooth": "Suave", + "ui.smooth_wheel_zoom": "Zoom suave con la rueda", + "ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior", + "ui.snap": "Ajustar a referencias", + "ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.", + "ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse", + "ui.spacing": "Espaciado", + "ui.spacing_2": "Espaciado 2", + "ui.sphere": "Esfera", + "ui.spline": "Spline", + "ui.split_body": "Dividir cuerpo", + "ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X", + "ui.start": "Iniciar", + "ui.start_radius": "Radio inicial", + "ui.supersampling": "Supermuestreo (SSAA)", + "ui.supersampling_adaptive": "Solo cuando la vista está inmóvil", + "ui.supersampling_off": "Desactivado (1×)", + "ui.suppress": "Suprimir", + "ui.sweep": "Barrido", + "ui.sweep_follow_direction": "Seguir dirección", + "ui.sweep_keep_as_drawn": "Mantener como se ha dibujado", + "ui.sweep_turn_with_path": "Girar con la trayectoria", + "ui.symmetric": "Simétrico", + "ui.symmetry": "Simetría", + "ui.system": "Sistema", + "ui.tangent": "Tangente", + "ui.taper_angle": "Ángulo de conicidad", + "ui.tapped": "Roscado", + "ui.text": "Texto", + "ui.theme": "Tema", + "ui.thickness": "Espesor", + "ui.thread": "Rosca", + "ui.timeline_bottom": "Línea de tiempo inferior", + "ui.timeline_left_note": "La disposición izquierda muestra la barra de retroceso encima del explorador de piezas y escenas.", + "ui.timeline_left_sidebar": "Barra lateral izquierda", + "ui.tip_angle": "Ángulo de punta", + "ui.to_object": "Hasta objeto", + "ui.tool_direction": "Dirección de herramienta", + "ui.top": "Superior", + "ui.torus": "Toro", + "ui.total_angle": "Ángulo total", + "ui.trackpad_pan_tip": "Los gestos del panel táctil desplazan la vista (desactivado: hacen zoom como la rueda); la rueda del ratón hace zoom, la rueda lateral desplaza horizontalmente y el gesto de pellizco siempre hace zoom", + "ui.trim": "Recortar", + "ui.twist": "Torsión", + "ui.two_finger_scroll_pans": "Desplazamiento con dos dedos", + "ui.two_sides": "Dos lados", + "ui.ui_scale": "Escala de interfaz", + "ui.undo": "Deshacer", + "ui.unground": "Liberar fijación", + "ui.unisolate": "Dejar de aislar", + "ui.units": "Unidades", + "ui.units_of_new_designs": "Unidades de diseños nuevos", + "ui.unlock": "Desbloquear", + "ui.unsuppress": "Dejar de suprimir", + "ui.untitled": "Sin título", + "ui.up_axis_note": "Eje que apunta hacia arriba en las vistas (vista de eje, Inicio, Mirar hacia). Los diseños no cambian.", + "ui.updateToCurrentView": "Actualizar a vista actual", + "ui.variable": "Variable", + "ui.view_centre": "Centro de la vista", + "ui.visual_shaded_hidden_edges": "Sombreado con aristas ocultas", + "ui.visual_shaded_with_edges": "Sombreado con aristas", + "ui.visual_style": "Estilo visual", + "ui.visual_wireframe": "Estructura alámbrica", + "ui.workspace": "espacio de trabajo", + "ui.x_distance": "Distancia X", + "ui.y_distance": "Distancia Y", + "ui.y_up": "Y hacia arriba", + "ui.z_up": "Z hacia arriba", + "ui.zoom": "Zoom", + "ui.zoom_to_cursor_tip": "La rueda hace zoom hacia el punto bajo el cursor (desactivado: hacia el centro de la vista)", + "ui.zoom_to_the_cursor": "Zoom hacia el cursor", + "update.available": "SolidCraft {version} está disponible", + "update.button": "Actualizar", + "update.button_tip": "SolidCraft {version} está disponible", + "update.button_version": "Actualizar a {version}", + "update.check_automatically": "Buscar actualizaciones automáticamente", + "update.check_now": "Comprobar ahora", + "update.checking": "Comprobando…", + "update.copy": "Copiar", + "update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra SolidCraft a Aplicaciones, sustituye la copia anterior y reinicia SolidCraft.", + "update.download": "Descargar", + "update.download_open": "Descargar y abrir", + "update.downloading": "Descargando y verificando… {done} de {total} MB", + "update.failed": "No se ha podido comprobar: {error}", + "update.file": "La descarga obtiene {file}.", + "update.install_failed": "No se ha podido instalar la actualización: {error}. No se ha modificado nada; la opción Descargar obtiene el paquete.", + "update.install_msi": "Instalar (cierra SolidCraft)", + "update.installed": "SolidCraft {version} está instalado. Reinicia para utilizarlo.", + "update.installer_started": "El instalador se iniciará cuando se cierre SolidCraft.", + "update.later": "Recordármelo más tarde", + "update.no_notes": "Esta versión no tiene notas.", + "update.package_hint": "Esta copia procede de {kind}. Descarga el paquete nuevo e instálalo con:", + "update.prefs_dev": "Esta es una compilación de desarrollo ({version}): no busca actualizaciones.", + "update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. SolidCraft no lo comprueba.", + "update.prefs_label": "Actualizaciones", + "update.prefs_note": "Esta es la versión {version} de SolidCraft ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.", + "update.release_page": "Abrir página de la versión", + "update.restart": "Reiniciar ahora", + "update.restart_later": "Más tarde", + "update.show": "Mostrar actualización a {version}", + "update.skip": "Omitir esta versión", + "update.system_hint": "Esta copia es de tipo {kind}. Actualízala con:", + "update.title": "Actualización disponible", + "update.up_to_date": "SolidCraft está actualizado.", + "update.update_now": "Actualizar ahora", + "update.you_have": "Tienes la versión {version}." +} From 45742f79e44b4920aa6c13cc495478d5bf6fc888 Mon Sep 17 00:00:00 2001 From: Jawler <84626502+jawler@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:25:04 +0200 Subject: [PATCH 012/149] Update i18n.rs --- crates/ui-egui/src/i18n.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ff8ac778..97d456fb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -33,6 +33,7 @@ impl Catalog { static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json"))); static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json"))); +static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json"))); static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json"))); static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json"))); static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json"))); @@ -46,6 +47,7 @@ static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env! pub const LOCALES: &[&Catalog] = &[ &ENGLISH, &GERMAN, + &SPANISH, &FRENCH, &JAPANESE, &KOREAN, @@ -466,6 +468,18 @@ mod tests { assert_ne!(de.given_name("Sketch3"), "Sketch3"); } + #[test] + fn spanish_catalog_is_registered_and_translates_cad_labels() { + let es = Locale::for_language("es"); + assert_eq!(es.name(), "Español (España)"); + assert!(is_supported("es")); + assert_eq!(es.text("ui.preferences"), "Preferencias"); + assert_eq!(es.label("Extrude"), "Extruir"); + assert_eq!(es.label("Fillet"), "Redondeo"); + assert_eq!(es.dynamic("Sketch1"), "Boceto1"); + assert_eq!(es.format("document.save_changes", &[("name", "Pieza")]), "¿Guardar los cambios de Pieza?"); + } + #[test] fn korean_catalog_translates_keys_labels_and_browser_patterns() { let ko = Locale::for_language("ko"); From 8207f29fc051b56bed5f22d3f09a13ba14e01637 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:26:12 +0900 Subject: [PATCH 013/149] Register `surface.boundary`, which the 2/3 squash left unregistered (#1701) Thanks @midasdf. --- crates/engine/src/cmd/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 34883809..e7380d9d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -74,6 +74,7 @@ mod sketch_modify; mod sketch_project; mod surface; mod surface_analysis; +mod surface_boundary; mod surface_match; mod view_dxf; mod view_navigation; @@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_canvas::COMMANDS.iter()); v.extend(sketch_3d::COMMANDS.iter()); v.extend(surface_analysis::COMMANDS.iter()); + v.extend(surface_boundary::COMMANDS.iter()); v.extend(surface::COMMANDS.iter()); v.extend(surface_match::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); From 2ae21143ee2cb8ffb3af916ee2ffadac956a9faa Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:26:15 +0200 Subject: [PATCH 014/149] Motion animation export: a study or a joint drive as an animated GIF (#1680) Thanks @SychPL. --- Cargo.lock | 1 + Cargo.toml | 2 + assets/locales/de.json | 3 + assets/locales/en.json | 3 + assets/locales/fr.json | 3 + assets/locales/ja.json | 3 + assets/locales/ko.json | 3 + assets/locales/ru.json | 3 + assets/locales/tr.json | 3 + assets/locales/uk.json | 3 + assets/locales/zh.json | 3 + crates/engine/src/cmd/motion.rs | 65 +++-- crates/engine/src/cmd/motion_animation.rs | 154 +++++++++++ crates/engine/src/cmd/motion_tests.rs | 232 ++++++++++++++++ crates/io/src/vfs.rs | 9 + crates/render/Cargo.toml | 1 + crates/render/src/gif.rs | 321 ++++++++++++++++++++++ crates/render/src/lib.rs | 2 + crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs_motion.rs | 95 ++++++- docs/book/src/guide/assemblies.md | 17 ++ docs/book/src/guide/getting-started.md | 1 + skills/solidcraft-mechanisms/SKILL.md | 2 +- skills/solidcraft-mechanisms/reference.md | 4 +- 24 files changed, 902 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/motion_animation.rs create mode 100644 crates/render/src/gif.rs diff --git a/Cargo.lock b/Cargo.lock index 1fa23852..684cb308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4215,6 +4215,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "solidcraft-geom", + "weezl", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d48e8a61..080e0184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # images image = { version = "0.25", default-features = false, features = ["png", "hdr"] } png = "0.18.1" +# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff). +weezl = "0.1.12" # ui egui = "0.36" diff --git a/assets/locales/de.json b/assets/locales/de.json index c74bd78c..a9b927c9 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -24,6 +24,7 @@ "Drive Joint": "Gelenk antreiben", "Edges": "Kanten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", + "Export Animation…": "Animation exportieren…", "Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren", "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", "Export DXF…": "DXF exportieren…", @@ -59,6 +60,7 @@ "Restore outer boundary": "Äußere Begrenzung wiederherstellen", "Revert Position": "Position zurücksetzen", "Round": "Rund", + "Save the study as an animated GIF, seen as the viewport shows it": "Die Studie als animiertes GIF speichern, so wie der Ansichtsbereich sie zeigt", "Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)", "Selection filter": "Auswahlfilter", "Send your report on GitHub": "Bericht auf GitHub senden", @@ -225,6 +227,7 @@ "cmd.model_threads": "Gewinde auflisten", "cmd.motion_delete": "Bewegungsuntersuchung löschen", "cmd.motion_export": "Bewegungsuntersuchung exportieren", + "cmd.motion_export_animation": "Bewegungsanimation exportieren", "cmd.motion_list": "Bewegungsuntersuchungen auflisten", "cmd.motion_play": "Bewegungsuntersuchung abspielen", "cmd.motion_study": "Bewegungsuntersuchung", diff --git a/assets/locales/en.json b/assets/locales/en.json index ffbe52a3..b2238047 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -92,6 +92,7 @@ "Enable rule": "Enable rule", "Enabled": "Enabled", "Explode": "Explode", + "Export Animation…": "Export Animation…", "Export Current View as DXF": "Export Current View as DXF", "Export Current View as DXF…": "Export Current View as DXF…", "Export DXF…": "Export DXF…", @@ -211,6 +212,7 @@ "Round": "Round", "Rule": "Rule", "Save Rule": "Save Rule", + "Save the study as an animated GIF, seen as the viewport shows it": "Save the study as an animated GIF, seen as the viewport shows it", "Saved preview. Geometry is being checked before editing.": "Saved preview. Geometry is being checked before editing.", "Scale factor": "Scale factor", "Search": "Search", @@ -426,6 +428,7 @@ "cmd.model_threads": "List Threads", "cmd.motion_delete": "Delete Motion Study", "cmd.motion_export": "Export Motion Study", + "cmd.motion_export_animation": "Export Motion Animation", "cmd.motion_list": "List Motion Studies", "cmd.motion_play": "Play Motion Study", "cmd.motion_study": "Motion Study", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 8e52631c..bd86bf26 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -85,6 +85,7 @@ "Enable rule": "Activer la règle", "Enabled": "Activé", "Explode": "Éclater", + "Export Animation…": "Exporter l'animation…", "Extents": "Étendues", "Face": "Face", "Face attachment lost": "Rattachement à la face perdu", @@ -194,6 +195,7 @@ "Round": "Arrondi", "Rule": "Règle", "Save Rule": "Enregistrer la règle", + "Save the study as an animated GIF, seen as the viewport shows it": "Enregistrer l'étude en GIF animé, telle que la fenêtre d'affichage la montre", "Saved preview. Geometry is being checked before editing.": "Aperçu enregistré. La géométrie est en cours de vérification avant modification.", "Scale factor": "Facteur d'échelle", "Search": "Rechercher", @@ -406,6 +408,7 @@ "cmd.model_threads": "Lister les filetages", "cmd.motion_delete": "Supprimer l'étude de mouvement", "cmd.motion_export": "Exporter l'étude de mouvement", + "cmd.motion_export_animation": "Exporter l'animation du mouvement", "cmd.motion_list": "Lister les études de mouvement", "cmd.motion_play": "Lire l'étude de mouvement", "cmd.motion_study": "Étude de mouvement", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 4bb4502f..9086717a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -92,6 +92,7 @@ "Enable rule": "ルールを有効化", "Enabled": "有効", "Explode": "分解", + "Export Animation…": "アニメーションをエクスポート…", "Export Current View as DXF": "現在の表示をDXFでエクスポート", "Export Current View as DXF…": "現在の表示をDXFでエクスポート…", "Export DXF…": "DXFをエクスポート…", @@ -211,6 +212,7 @@ "Round": "丸め", "Rule": "ルール", "Save Rule": "ルールを保存", + "Save the study as an animated GIF, seen as the viewport shows it": "ビューポートの表示どおりにスタディをアニメーション GIF として保存", "Saved preview. Geometry is being checked before editing.": "保存済みのプレビューです。編集前にジオメトリを検証しています。", "Scale factor": "尺度係数", "Search": "検索", @@ -426,6 +428,7 @@ "cmd.model_threads": "ねじを一覧表示", "cmd.motion_delete": "モーション スタディを削除", "cmd.motion_export": "モーション スタディをエクスポート", + "cmd.motion_export_animation": "モーション アニメーションをエクスポート", "cmd.motion_list": "モーション スタディを一覧表示", "cmd.motion_play": "モーション スタディを再生", "cmd.motion_study": "モーション スタディ", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a274a358..5d2e3538 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -24,6 +24,7 @@ "Drive Joint": "조인트 구동", "Edges": "모서리", "Edit Profile Sketch": "윤곽 스케치 편집", + "Export Animation…": "애니메이션 내보내기…", "Export Current View as DXF": "현재 뷰를 DXF로 내보내기", "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", "Export DXF…": "DXF 내보내기…", @@ -59,6 +60,7 @@ "Restore outer boundary": "외부 경계 복원", "Revert Position": "위치 복원", "Round": "둥글게", + "Save the study as an animated GIF, seen as the viewport shows it": "뷰포트에 보이는 대로 스터디를 애니메이션 GIF로 저장", "Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)", "Selection filter": "선택 필터", "Send your report on GitHub": "GitHub에서 보고서 보내기", @@ -225,6 +227,7 @@ "cmd.model_threads": "나사산 목록", "cmd.motion_delete": "운동 연구 삭제", "cmd.motion_export": "운동 연구 내보내기", + "cmd.motion_export_animation": "모션 애니메이션 내보내기", "cmd.motion_list": "운동 연구 목록", "cmd.motion_play": "운동 연구 재생", "cmd.motion_study": "운동 연구", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 30f1ab3b..5df35119 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -81,6 +81,7 @@ "Enable rule": "Включить правило", "Enabled": "Включено", "Explode": "Разобрать", + "Export Animation…": "Экспортировать анимацию…", "Extents": "Пределы", "Face attachment lost": "Привязка к грани потеряна", "Faces": "Грани", @@ -177,6 +178,7 @@ "Round": "Круглая", "Rule": "Правило", "Save Rule": "Сохранить правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Сохранить исследование как анимированный GIF, как его показывает видовой экран", "Saved preview. Geometry is being checked before editing.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.", "Scale factor": "Коэффициент масштаба", "Search": "Поиск", @@ -378,6 +380,7 @@ "cmd.model_threads": "Список резьб", "cmd.motion_delete": "Удалить исследование движения", "cmd.motion_export": "Экспортировать исследование движения", + "cmd.motion_export_animation": "Экспортировать анимацию движения", "cmd.motion_list": "Список исследований движения", "cmd.motion_play": "Воспроизвести исследование движения", "cmd.motion_study": "Исследование движения", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index e916335a..eb75ca65 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -85,6 +85,7 @@ "Enable rule": "Kuralı etkinleştir", "Enabled": "Etkin", "Explode": "Patlat", + "Export Animation…": "Animasyonu dışa aktar…", "Extents": "Kapsamlar", "Face": "Yüz", "Face attachment lost": "Yüze bağlama kayboldu", @@ -193,6 +194,7 @@ "Round": "Yuvarlak", "Rule": "Kural", "Save Rule": "Kuralı kaydet", + "Save the study as an animated GIF, seen as the viewport shows it": "Etüdü görünüm alanında göründüğü gibi animasyonlu GIF olarak kaydet", "Saved preview. Geometry is being checked before editing.": "Kaydedilmiş önizleme. Düzenlemeden önce geometri kontrol ediliyor.", "Scale factor": "Ölçek faktörü", "Search": "Ara", @@ -400,6 +402,7 @@ "cmd.model_threads": "Dişleri listele", "cmd.motion_delete": "Hareket etüdünü sil", "cmd.motion_export": "Hareket etüdünü dışa aktar", + "cmd.motion_export_animation": "Hareket animasyonunu dışa aktar", "cmd.motion_list": "Hareket etütlerini listele", "cmd.motion_play": "Hareket etüdünü oynat", "cmd.motion_study": "Hareket etüdü", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 86c162c3..d906673d 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -92,6 +92,7 @@ "Enable rule": "Увімкнути правило", "Enabled": "Увімкнено", "Explode": "Рознести", + "Export Animation…": "Експортувати анімацію…", "Export Current View as DXF": "Експортувати поточний вид як DXF", "Export Current View as DXF…": "Експортувати поточний вид як DXF…", "Export DXF…": "Експорт DXF…", @@ -211,6 +212,7 @@ "Round": "Круглий", "Rule": "Правило", "Save Rule": "Зберегти правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Зберегти дослідження як анімований GIF, як його показує вікно перегляду", "Saved preview. Geometry is being checked before editing.": "Збережений перегляд. Геометрію перевіряють перед редагуванням.", "Scale factor": "Коефіцієнт масштабу", "Search": "Пошук", @@ -426,6 +428,7 @@ "cmd.model_threads": "Список різьб", "cmd.motion_delete": "Видалити дослідження руху", "cmd.motion_export": "Експортувати дослідження руху", + "cmd.motion_export_animation": "Експортувати анімацію руху", "cmd.motion_list": "Список досліджень руху", "cmd.motion_play": "Відтворити дослідження руху", "cmd.motion_study": "Дослідження руху", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index b310f839..e9acd94e 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -92,6 +92,7 @@ "Enable rule": "启用规则", "Enabled": "已启用", "Explode": "爆炸", + "Export Animation…": "导出动画…", "Export Current View as DXF": "将当前视图导出为DXF", "Export Current View as DXF…": "将当前视图导出为DXF…", "Export DXF…": "导出DXF…", @@ -211,6 +212,7 @@ "Round": "圆角", "Rule": "规则", "Save Rule": "保存规则", + "Save the study as an animated GIF, seen as the viewport shows it": "按视口中的显示将研究保存为动画 GIF", "Saved preview. Geometry is being checked before editing.": "已保存预览。编辑前正在检查几何。", "Scale factor": "比例因子", "Search": "搜索", @@ -426,6 +428,7 @@ "cmd.model_threads": "列出螺纹", "cmd.motion_delete": "删除运动研究", "cmd.motion_export": "导出运动研究", + "cmd.motion_export_animation": "导出运动动画", "cmd.motion_list": "列出运动研究", "cmd.motion_play": "播放运动研究", "cmd.motion_study": "运动研究", diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 2e575911..cc42c151 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -14,6 +14,8 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, reso use crate::params::{bad, bool_, str_, vec3}; use crate::{EngineError, Result, Session}; +#[path = "motion_animation.rs"] +mod animation; #[path = "contact_sweep.rs"] mod contact_sweep; @@ -54,6 +56,9 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("motion.export", "Export Motion Study", export) .noundo() .params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"), + CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).noundo().params( + "study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)", + ), CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!( trace_doc!(), " → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \ @@ -718,19 +723,45 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> let local = solidcraft_doc::mat_inverse(&world(&t)?) .map(|m| solidcraft_doc::apply_point(&m, at)) .ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?; - let samples = |default: usize| match p.get("samples") { + let (default, given, plan) = motion_plan(s, p, cmd)?; + let n = sample_count(p, default.min(MAX_TRACE), MAX_TRACE, cmd)?; + let mut points = Vec::new(); + let mut stop = (Value::Null, Value::Null, Value::Null); + for k in 0..n { + let want = plan(&t, k, n); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + points.push(solidcraft_doc::apply_point(&world(&t)?, local)); + if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { + stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); + break; + } + } + let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); + let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2}); + Ok((points, out)) +} + +/// `samples`, 2…`max`, or `default` without it. +fn sample_count(p: &Value, default: usize, max: usize, cmd: &str) -> Result { + match p.get("samples") { Some(v) => v .as_f64() - .filter(|n| n.is_finite() && *n >= 2.0 && *n <= MAX_TRACE as f64) + .filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64) .map(|n| n.round() as usize) - .ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))), - None => Ok(default), - }; - let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) { + .ok_or_else(|| bad(cmd, format!("`samples` must be 2…{max}"))), + None => Ok(default.max(2)), + } +} + +/// The motion a trace or an animation samples: a study played from start to end, or one joint +/// value driven from `from` to `to`. Returns the default sample count (the study's steps + 1, or +/// 100), the values driven and what they are at sample `k` of `n`. +fn motion_plan(s: &Session, p: &Value, cmd: &str) -> Result<(usize, BTreeSet<(u64, usize)>, Plan)> { + Ok(match (p.get("study"), p.get("joint")) { (Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")), (Some(_), None) => { let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; - let n = samples((m.steps as usize).saturating_add(1).clamp(2, MAX_TRACE))?; + let n = (m.steps as usize).saturating_add(1); (n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64))) } (None, Some(j)) => { @@ -750,7 +781,7 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> }; let end = |key: &str| match p.get(key) { Some(v) => value(s, v, kind.is_angle(index), cmd), - None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the trace runs between (no `{key}`)"))), + None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)"))), }; let (from, to) = (end("from")?, end("to")?); let plan: Plan = Box::new(move |t, k, n| { @@ -761,24 +792,10 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> } vec![(id, q)] }); - (samples(100)?, BTreeSet::from([(id, index)]), plan) + (100, BTreeSet::from([(id, index)]), plan) } (None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")), - }; - let mut points = Vec::new(); - let mut stop = (Value::Null, Value::Null, Value::Null); - for k in 0..n { - let want = plan(&t, k, n); - let (r, _) = drive_with_contact(&mut t, &want, &given)?; - points.push(solidcraft_doc::apply_point(&world(&t)?, local)); - if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { - stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); - break; - } - } - let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); - let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2}); - Ok((points, out)) + }) } fn trace(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/motion_animation.rs b/crates/engine/src/cmd/motion_animation.rs new file mode 100644 index 00000000..53bd0795 --- /dev/null +++ b/crates/engine/src/cmd/motion_animation.rs @@ -0,0 +1,154 @@ +//! Motion animations (`motion.export_animation`): a study played, or one joint driven, drawn +//! frame by frame by the headless renderer the host installed ([`crate::capture`]) and written as +//! an animated GIF or a folder of PNG frames. Sampled on a copy, as a trace is: the design is not +//! changed. + +use serde_json::{Value, json}; + +use super::{drive_with_contact, motion_plan, sample_count}; +use crate::capture::{self, Renderer}; +use crate::params::{bad, str_}; +use crate::render::Camera; +use crate::{Result, Session}; + +/// Most frames of one animation. +const MAX_FRAMES: usize = 1000; +/// Largest frame side, in pixels. +const MAX_SIDE: u32 = 4096; +/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the +/// palette is chosen, so 256 MB at most. +const MAX_GIF_PIXELS: usize = 1 << 26; +/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about +/// 500 frames of 1920 × 1080. +const MAX_PNG_PIXELS: usize = 1 << 30; +/// The camera fields `camera` may set: those of `ui.camera`'s reply. +const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"]; + +pub(super) fn export(s: &mut Session, p: &Value) -> Result { + export_with(s, p, capture::installed().as_deref()) +} + +/// [`export`] drawn by `renderer`. +pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -> Result { + let cmd = "motion.export_animation"; + let path = str_(p, "path") + .filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control)) + .ok_or_else(|| bad(cmd, "`path` must name a .gif file, or a folder for PNG frames"))?; + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".png") { + return Err(bad(cmd, "`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png")); + } + let gif = lower.ends_with(".gif"); + // In the browser written files stay in memory until the host takes them, and the host is not + // told of removed ones: a folder of frames would pile up there. + if !gif && cfg!(target_arch = "wasm32") { + return Err(bad(cmd, "a folder of PNG frames is not available in the browser: give a .gif path")); + } + let side = |key: &str, default: u32| match p.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n)) + .map(|n| n.round() as u32) + .ok_or_else(|| bad(cmd, format!("`{key}` must be 16…{MAX_SIDE} pixels"))), + }; + let (width, height) = (side("width", 800)?, side("height", 500)?); + let fps = match p.get("fps") { + None => 20.0, + Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or_else(|| bad(cmd, "`fps` must be 1…50"))?, + }; + let cam = camera(s, p.get("camera"), cmd)?; + let shown = |key: &str| match p.get(key) { + None => Ok(true), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + }; + let guides = crate::view::Guides { grid: shown("grid")?, axes: shown("axes")? }; + let (default, given, plan) = motion_plan(s, p, cmd)?; + // At least 4 frames fit even at the largest size. + let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize)); + let n = sample_count(p, default.min(max), max, cmd).map_err(|_| bad(cmd, format!("`samples` must be 2…{max} for {width} × {height} frames")))?; + let renderer = renderer.ok_or_else(|| bad(cmd, "no renderer is installed to draw pictures in this process"))?; + // A destination outside an agent's folders is refused before anything is drawn. + let io = |e: std::io::Error| bad(cmd, format!("{path}: {e}")); + solidcraft_io::vfs::check(path).map_err(io)?; + let frame_path = |k: usize| std::path::Path::new(path).join(format!("frame-{k:04}.png")).to_string_lossy().to_string(); + if !gif { + solidcraft_io::vfs::create_dir_all(path).map_err(io)?; + // The frames of an earlier export here go first, so the folder never mixes two + // animations: not after a shorter one, nor when this one fails part way. + for stale in (1..=MAX_FRAMES).map(frame_path).filter(|f| solidcraft_io::vfs::exists(f)) { + solidcraft_io::vfs::remove(&stale).map_err(|e| bad(cmd, format!("{stale}: {e}")))?; + } + } + let mut t = s.scratch(); + // From where the parts are placed: moves only shown (an exploded view, the pose an open + // Motion Study dialog shows) are not where the joints are solved. + t.pending_moves.clear(); + let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, ""); + let mut stop = (Value::Null, Value::Null, Value::Null); + for k in 0..n { + let want = plan(&t, k, n); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) { + stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); + } + let scene = crate::view::scene_with_guides(&t, &cam, s.section, guides); + let picture = capture::rgba_with(Some(renderer), &scene, &cam, width, height).map_err(|e| bad(cmd, e))?; + drawn_by = picture.renderer; + if gif { + frames.push(picture.rgba); + } else { + let png = crate::render::png(width as usize, height as usize, picture.rgba).ok_or_else(|| bad(cmd, "PNG encoding failed"))?; + let file = frame_path(k + 1); + solidcraft_io::vfs::write(&file, &png).map_err(|e| bad(cmd, format!("{file}: {e}")))?; + bytes += png.len(); + } + } + let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height}); + if gif { + // GIF counts in hundredths of a second, and viewers slow anything under 2 down. + let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16; + let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?; + solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?; + bytes = gif.len(); + out["fps"] = json!(100.0 / f64::from(delay)); + } + out["bytes"] = json!(bytes); + out["renderer"] = json!(drawn_by); + if let Some(w) = capture::warning(drawn_by) { + out["warning"] = json!(w); + } + out["stopped_at"] = stop.0; + out["stopped_by_loop"] = stop.1; + out["stopped_by_contact"] = stop.2; + Ok(out) +} + +/// The camera the frames are drawn from: the session's (`view.apply`), or the home view framing +/// the model, with the fields `given` sets (`ui.camera`'s reply sets them all). +fn camera(s: &Session, given: Option<&Value>, cmd: &str) -> Result { + let base = s.camera.unwrap_or_else(|| crate::view::home_camera(s)); + let Some(given) = given else { return Ok(base) }; + let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?; + let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + for (k, x) in fields { + if !FIELDS.contains(&k.as_str()) { + return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", ")))); + } + if let Some(o) = v.as_object_mut() { + o.insert(k.clone(), x.clone()); + } + } + let mut cam: Camera = serde_json::from_value(v).map_err(|e| bad(cmd, format!("bad camera: {e}")))?; + if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) { + return Err(bad( + cmd, + "the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians", + )); + } + // Straight up or down keeps a heading, as ui.camera does. + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + cam.pitch = cam.pitch.clamp(-lim, lim); + Ok(cam) +} diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index 6ee3215a..44974d82 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -173,3 +173,235 @@ fn exploded_views_show_without_changing_the_design() { ); assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2)); } + +/// Pictures drawn by the CPU rasterizer (the engine has no GPU): real views of the scene. +fn cpu(scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32) -> Result { + Ok(crate::capture::Picture { rgba: crate::render::render(scene, cam, w as usize, h as usize).rgba, renderer: "cpu" }) +} + +/// A folder of its own for one test's files. +fn out_dir(test: &str) -> std::path::PathBuf { + let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-anim-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A GIF's logical screen size and the number of its frames (image descriptors). +fn gif_frames(gif: &[u8]) -> (usize, usize, usize) { + let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]])); + assert_eq!(&gif[..6], b"GIF89a"); + let mut at = 13 + if gif[10] & 0x80 != 0 { 3 << ((gif[10] & 7) + 1) } else { 0 }; + let mut frames = 0; + // Each block: a size byte and that many bytes, up to a 0. + let skip_blocks = |mut at: usize| { + while gif[at] != 0 { + at += usize::from(gif[at]) + 1; + } + at + 1 + }; + loop { + match gif[at] { + 0x3B => return (u16_at(6), u16_at(8), frames), + 0x21 => at = skip_blocks(at + 2), + 0x2C => { + frames += 1; + at = skip_blocks(at + 11); + } + b => panic!("unexpected GIF block {b:#x}"), + } + } +} + +/// A PNG's width and height (its IHDR). +fn png_size(png: &[u8]) -> (u32, u32) { + let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]); + (be(16), be(20)) +} + +#[test] +fn an_animation_has_the_frames_and_the_size_asked_for() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]})); + let dir = out_dir("size"); + let before = s.doc.clone(); + let gif = dir.join("push.gif"); + let p = json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 6, "width": 64, "height": 40, "fps": 25}); + let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_eq!( + (r["format"].as_str(), r["frames"].as_u64(), r["width"].as_u64(), r["height"].as_u64()), + (Some("gif"), Some(6), Some(64), Some(40)), + "{r}" + ); + assert_eq!(r["fps"], 25.0, "{r}"); + let bytes = std::fs::read(&gif).unwrap(); + assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64)); + assert_eq!(gif_frames(&bytes), (64, 40, 6)); + // Without samples: the study's steps + 1. + let r = + super::animation::export_with(&s, &json!({"study": "Push", "path": gif.to_string_lossy(), "width": 32, "height": 32}), Some(&cpu)).unwrap(); + assert_eq!(gif_frames(&std::fs::read(&gif).unwrap()), (32, 32, 11), "{r}"); + // A folder of PNG frames, made where missing; a joint driven instead of a study. + let frames = dir.join("frames").join("rail"); + let p = json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 3, "width": 48, "height": 30}); + let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_eq!((r["format"].as_str(), r["frames"].as_u64()), (Some("png"), Some(3)), "{r}"); + let mut names: Vec = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, ["frame-0001.png", "frame-0002.png", "frame-0003.png"]); + assert!(names.iter().all(|n| png_size(&std::fs::read(frames.join(n)).unwrap()) == (48, 30))); + // A shorter export into the same folder leaves no frames of a longer one behind, past a gap + // too; other files stay. + std::fs::write(frames.join("notes.txt"), "mine").unwrap(); + std::fs::write(frames.join("frame-0005.png"), "older").unwrap(); + super::animation::export_with(&s, &json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 2}), Some(&cpu)) + .unwrap(); + let mut names: Vec = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, ["frame-0001.png", "frame-0002.png", "notes.txt"]); + assert_eq!(*s.doc, *before, "the design is not changed"); + // Through the registry, with the renderer the host installed. + crate::capture::install_test_renderer(); + let r = run(&mut s, "motion.export_animation", json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 2, "width": 16, "height": 16})); + assert_eq!(r["frames"], 2, "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_two_frame_drive_gives_two_different_frames() { + let s = rail(); + let dir = out_dir("two"); + let frames = |name: &str, to: f64| { + let folder = dir.join(name); + let p = json!({"joint": "Rail", "from": 10, "to": to, "path": folder.to_string_lossy(), "samples": 2, "width": 120, "height": 80}); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + (std::fs::read(folder.join("frame-0001.png")).unwrap(), std::fs::read(folder.join("frame-0002.png")).unwrap()) + }; + let (a, b) = frames("moved", -5.0); + assert_ne!(a, b, "B moved 15 mm between the frames"); + // Held still, the two frames are the same picture: the difference is the motion. + let (c, d) = frames("still", 10.0); + assert_eq!(c, d); + assert_eq!(a, c, "both start at 10 mm"); + // Without the ground grid and the axes, the same pose is another picture. + let bare = dir.join("bare"); + let p = json!({"joint": "Rail", "from": 10, "to": 10, "path": bare.to_string_lossy(), "samples": 2, "width": 120, "height": 80, "grid": false, "axes": false}); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_ne!(std::fs::read(bare.join("frame-0001.png")).unwrap(), c); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_animation_is_drawn_from_the_camera_given() { + let mut s = rail(); + let dir = out_dir("camera"); + // A renderer is 'static (hosts install it for the process): it shares what it saw. + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = { + let seen = seen.clone(); + move |scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32| { + seen.lock().unwrap().push(*cam); + cpu(scene, cam, w, h) + } + }; + let path = dir.join("cam.gif"); + let base = json!({"joint": "Rail", "from": 10, "to": 0, "path": path.to_string_lossy(), "samples": 3, "width": 40, "height": 30}); + let with = |camera: Value| { + let mut p = base.clone(); + p["camera"] = camera; + p + }; + // ui.camera's reply, every field. + let reply = json!({"target": [1.0, 2.0, 3.0], "yaw": 0.5, "pitch": -0.25, "roll": 0.1, "distance": 300.0, "fov": 0.6, "up": "y"}); + super::animation::export_with(&s, &with(reply.clone()), Some(&record)).unwrap(); + let cams = std::mem::take(&mut *seen.lock().unwrap()); + assert_eq!(cams.len(), 3); + assert!(cams.iter().all(|c| json!(c) == reply), "{cams:?}"); + // Fields left out keep the default view: view.apply's camera. + run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80}})); + super::animation::export_with(&s, &with(json!({"yaw": 2.0})), Some(&record)).unwrap(); + let cams = std::mem::take(&mut *seen.lock().unwrap()); + assert!(cams.iter().all(|c| c.yaw == 2.0 && c.pitch == 0.2 && c.distance == 80.0), "{cams:?}"); + // Without one: view.apply's camera as it is; and another camera draws another picture. + super::animation::export_with(&s, &base, Some(&record)).unwrap(); + assert!(seen.lock().unwrap().iter().all(|c| Some(*c) == s.camera)); + let pictures = |camera: Value| { + let folder = dir.join(format!("p{}", camera["yaw"])); + let mut p = with(camera); + p["path"] = json!(folder.to_string_lossy()); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + std::fs::read(folder.join("frame-0001.png")).unwrap() + }; + assert_ne!(pictures(json!({"yaw": 0.3})), pictures(json!({"yaw": 1.3}))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn hostile_animation_params_are_refused() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]})); + let dir = out_dir("hostile"); + let gif = dir.join("x.gif").to_string_lossy().to_string(); + let base = json!({"study": "Push", "path": gif, "samples": 2, "width": 16, "height": 16}); + let refused = |change: Value| { + let mut p = base.clone(); + if let (Some(p), Some(change)) = (p.as_object_mut(), change.as_object()) { + for (k, v) in change { + if v.is_null() { + p.remove(k); + } else { + p.insert(k.clone(), v.clone()); + } + } + } + super::animation::export_with(&s, &p, Some(&cpu)).err().map(|e| e.to_string()) + }; + for change in [ + json!({"samples": 0}), + json!({"samples": 1}), + json!({"samples": 1e9}), + json!({"samples": "many"}), + json!({"samples": 1001}), + json!({"width": 1e9}), + json!({"width": -16}), + json!({"height": 3}), + json!({"height": f64::MAX}), + json!({"fps": 0}), + json!({"fps": 1e9}), + json!({"path": ""}), + json!({"path": null}), + json!({"path": "one.png"}), + json!({"path": "a\u{0}b.gif"}), + json!({"path": "x".repeat(5000)}), + json!({"camera": "iso"}), + json!({"camera": {"distance": -1}}), + json!({"camera": {"distance": 1e300}}), + json!({"camera": {"pitch": 4}}), + json!({"camera": {"fov": 9}}), + json!({"camera": {"target": [1, 2]}}), + json!({"camera": {"fit": true}}), + json!({"grid": "no"}), + json!({"axes": 1}), + json!({"study": null}), + json!({"study": "Nope"}), + json!({"study": null, "joint": "Rail"}), + // 4096 × 4096 frames: a GIF holds four of them. + json!({"width": 4096, "height": 4096, "samples": 5}), + ] { + assert!(refused(change.clone()).is_some(), "{change} was accepted"); + } + let e = refused(json!({"width": 4096, "height": 4096, "samples": 5})).unwrap(); + assert!(e.contains("2…4 for 4096 × 4096"), "{e}"); + assert!(super::animation::export_with(&s, &base, None).unwrap_err().to_string().contains("no renderer")); + assert!(!std::path::Path::new(&gif).exists(), "nothing refused was written"); + // An agent's export lands only inside its folders. + let root = dir.join("root"); + std::fs::create_dir_all(&root).unwrap(); + solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || { + let e = refused(json!({"path": dir.join("out").to_string_lossy()})).unwrap(); + assert!(e.contains("outside the folders this agent may use"), "{e}"); + assert!(refused(json!({"path": root.join("in.gif").to_string_lossy()})).is_none()); + }); + assert!(root.join("in.gif").exists() && !dir.join("out").exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs index 5749f2af..05b6ebae 100644 --- a/crates/io/src/vfs.rs +++ b/crates/io/src/vfs.rs @@ -50,6 +50,11 @@ mod imp { check(path)?; std::fs::remove_file(path) } + /// A folder and the folders above it, where missing. + pub fn create_dir_all(path: &str) -> Result<()> { + check(path)?; + std::fs::create_dir_all(path) + } } #[cfg(target_arch = "wasm32")] @@ -94,6 +99,10 @@ mod imp { pub fn remove(path: &str) -> Result<()> { FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path)) } + /// Files are keyed by their whole path here: a folder needs no making. + pub fn create_dir_all(_path: &str) -> Result<()> { + Ok(()) + } pub fn insert(path: &str, bytes: Vec) { FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes)); } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index b988a4ad..c31f0b8a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -15,6 +15,7 @@ image = { workspace = true, features = ["jpeg"] } moxcms = { workspace = true } png = { workspace = true } sha2 = { workspace = true } +weezl = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/gif.rs b/crates/render/src/gif.rs new file mode 100644 index 00000000..278708a8 --- /dev/null +++ b/crates/render/src/gif.rs @@ -0,0 +1,321 @@ +//! Animated GIF (GIF89a) from RGBA frames, for motion animations. Every frame shares one palette, +//! so a colour keeps its value from frame to frame, and each frame after the first stores only +//! the rectangle that changed. The palette splits the frames' colours where that removes the +//! most squared colour error, so a small, strongly coloured part (a red part, a thin trace line) +//! keeps its colour instead of being averaged into the shading around it. No dithering: what +//! stands still stays still. + +use std::collections::HashMap; + +/// Colours a GIF palette holds. +const PALETTE: usize = 256; + +/// Frames (straight RGBA, `width × height`, rows top to bottom; alpha is ignored) as a GIF that +/// loops forever and shows each frame for `delay` hundredths of a second. `None` without frames, +/// for a frame of the wrong size, or a side GIF can't store (above 65535). +pub fn gif(width: usize, height: usize, frames: &[Vec], delay: u16) -> Option> { + let (w, h) = (u16::try_from(width).ok().filter(|w| *w > 0)?, u16::try_from(height).ok().filter(|h| *h > 0)?); + let bytes = width.checked_mul(height)?.checked_mul(4)?; + if frames.is_empty() || frames.iter().any(|f| f.len() != bytes) { + return None; + } + let (palette, index) = palette(frames); + let mut out = Vec::with_capacity(1024); + out.extend_from_slice(b"GIF89a"); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + // A global table of 256 colours (8 bits each), background colour 0, square pixels. + out.extend_from_slice(&[0xF7, 0, 0]); + for k in 0..PALETTE { + out.extend_from_slice(palette.get(k).unwrap_or(&[0, 0, 0])); + } + // Loop forever (the NETSCAPE2.0 extension every viewer reads). + out.extend_from_slice(&[0x21, 0xFF, 0x0B]); + out.extend_from_slice(b"NETSCAPE2.0"); + out.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]); + let mut previous: Option> = None; + for frame in frames { + let now = indices(frame, &index); + let (x0, y0, x1, y1) = previous.as_deref().map_or(Some((0, 0, width - 1, height - 1)), |p| changed(p, &now, width)).unwrap_or((0, 0, 0, 0)); + let mut rect = Vec::with_capacity((x1 - x0 + 1) * (y1 - y0 + 1)); + for y in y0..=y1 { + rect.extend_from_slice(now.get(y * width + x0..=y * width + x1)?); + } + // Graphic control: kept on screen under the next frame (disposal 1), shown `delay`. + out.extend_from_slice(&[0x21, 0xF9, 0x04, 0x04]); + out.extend_from_slice(&delay.to_le_bytes()); + out.extend_from_slice(&[0, 0]); + out.push(0x2C); + for v in [x0, y0, x1 - x0 + 1, y1 - y0 + 1] { + out.extend_from_slice(&u16::try_from(v).ok()?.to_le_bytes()); + } + out.push(0); + out.push(8); + let data = weezl::encode::Encoder::new(weezl::BitOrder::Lsb, 8).encode(&rect).ok()?; + for block in data.chunks(255) { + out.push(block.len() as u8); + out.extend_from_slice(block); + } + out.push(0); + previous = Some(now); + } + out.push(0x3B); + Some(out) +} + +/// The pixels' colours, `0xRRGGBB`. +fn colours(frame: &[u8]) -> impl Iterator + '_ { + frame.as_chunks::<4>().0.iter().map(|&[r, g, b, _]| (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) +} + +/// A frame's palette indices. +fn indices(frame: &[u8], index: &HashMap) -> Vec { + let mut last = (u32::MAX, 0); + colours(frame) + .map(|k| { + if k != last.0 { + last = (k, index.get(&k).copied().unwrap_or(0)); + } + last.1 + }) + .collect() +} + +/// The smallest rectangle (x0, y0, x1, y1, inclusive) holding every pixel that differs, or `None` +/// when none does. +fn changed(before: &[u8], now: &[u8], width: usize) -> Option<(usize, usize, usize, usize)> { + let mut r: Option<(usize, usize, usize, usize)> = None; + for (i, _) in before.iter().zip(now).enumerate().filter(|(_, (a, b))| a != b) { + let (x, y) = (i % width, i / width); + r = Some(r.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y)))); + } + r +} + +/// How many pixels have a colour, and the sums its mean and squared error follow from. +#[derive(Clone, Copy, Default)] +struct Sums { + n: f64, + s: [f64; 3], + q: f64, +} + +impl Sums { + fn of(rgb: u32, n: u64) -> Sums { + let n = n as f64; + let c = [f64::from((rgb >> 16) as u8), f64::from((rgb >> 8) as u8), f64::from(rgb as u8)]; + Sums { n, s: c.map(|v| n * v), q: n * c.iter().map(|v| v * v).sum::() } + } + fn add(&mut self, o: &Sums) { + self.n += o.n; + self.q += o.q; + self.s.iter_mut().zip(o.s).for_each(|(a, b)| *a += b); + } + fn minus(&self, o: &Sums) -> Sums { + let mut s = *self; + s.n -= o.n; + s.q -= o.q; + s.s.iter_mut().zip(o.s).for_each(|(a, b)| *a -= b); + s + } + /// The squared distance of the pixels from their mean colour. + fn error(&self) -> f64 { + if self.n <= 0.0 { 0.0 } else { self.q - self.s.iter().map(|s| s * s).sum::() / self.n } + } + fn mean(&self) -> [u8; 3] { + self.s.map(|s| if self.n > 0.0 { (s / self.n).round().clamp(0.0, 255.0) as u8 } else { 0 }) + } +} + +/// A set of the frames' colours that becomes one palette entry, and its best split: (error +/// removed, channel, the largest value of that channel on the low side). +struct Group { + colours: Vec<(u32, Sums)>, + sums: Sums, + split: Option<(f64, u32, u8)>, +} + +impl Group { + fn new(colours: Vec<(u32, Sums)>) -> Group { + let mut sums = Sums::default(); + colours.iter().for_each(|(_, s)| sums.add(s)); + let mut best: Option<(f64, u32, u8)> = None; + for channel in [16u32, 8, 0] { + let mut bins = [Sums::default(); 256]; + for (k, s) in &colours { + if let Some(b) = bins.get_mut(usize::from((k >> channel) as u8)) { + b.add(s); + } + } + let mut low = Sums::default(); + for (v, b) in bins.iter().enumerate() { + low.add(b); + if low.n <= 0.0 || low.n >= sums.n { + continue; + } + let gain = sums.error() - low.error() - sums.minus(&low).error(); + if best.is_none_or(|(g, _, _)| gain > g) { + best = Some((gain, channel, v as u8)); + } + } + } + Group { colours, sums, split: best.filter(|(g, _, _)| *g > 0.0) } + } +} + +/// One palette for all frames (up to 256 colours) and each colour's index in it. Every colour +/// gets its own entry while there are 256 or fewer. +fn palette(frames: &[Vec]) -> (Vec<[u8; 3]>, HashMap) { + let mut counts: HashMap = HashMap::new(); + for frame in frames { + // Runs of one colour (the background) are counted once per run. + let mut run = (u32::MAX, 0u64); + for k in colours(frame) { + if k != run.0 { + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + run = (k, 0); + } + run.1 += 1; + } + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + } + let mut groups = vec![Group::new(counts.into_iter().map(|(k, n)| (k, Sums::of(k, n))).collect())]; + while groups.len() < PALETTE { + let Some(i) = groups.iter().enumerate().filter_map(|(i, g)| g.split.map(|s| (i, s.0))).max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) + else { + break; + }; + let g = groups.swap_remove(i); + let Some((_, channel, at)) = g.split else { break }; + let (low, high) = g.colours.into_iter().partition(|(k, _)| (k >> channel) as u8 <= at); + groups.push(Group::new(low)); + groups.push(Group::new(high)); + } + let mut index = HashMap::new(); + for (i, g) in groups.iter().enumerate() { + for (k, _) in &g.colours { + index.insert(*k, i as u8); + } + } + (groups.iter().map(|g| g.sums.mean()).collect(), index) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frames of a GIF this module wrote, as RGBA (each frame drawn over the one before). + fn decode(gif: &[u8]) -> (usize, usize, Vec>, Vec) { + let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]])); + assert_eq!(&gif[..6], b"GIF89a"); + let (w, h) = (u16_at(6), u16_at(8)); + let table = &gif[13..13 + 768]; + let mut at = 13 + 768; + let (mut frames, mut delays) = (Vec::new(), Vec::new()); + let mut canvas = vec![0u8; w * h * 4]; + loop { + match gif[at] { + 0x3B => break, + 0x21 => { + if gif[at + 1] == 0xF9 { + delays.push(u16_at(at + 4) as u16); + } + at += 2; + while gif[at] != 0 { + at += usize::from(gif[at]) + 1; + } + at += 1; + } + 0x2C => { + let (x0, y0, fw, fh) = (u16_at(at + 1), u16_at(at + 3), u16_at(at + 5), u16_at(at + 7)); + at += 11; + let mut data = Vec::new(); + while gif[at] != 0 { + data.extend_from_slice(&gif[at + 1..at + 1 + usize::from(gif[at])]); + at += usize::from(gif[at]) + 1; + } + at += 1; + let pixels = weezl::decode::Decoder::new(weezl::BitOrder::Lsb, 8).decode(&data).unwrap(); + assert_eq!(pixels.len(), fw * fh); + for (i, p) in pixels.iter().enumerate() { + let o = ((y0 + i / fw) * w + x0 + i % fw) * 4; + let c = usize::from(*p) * 3; + canvas[o..o + 4].copy_from_slice(&[table[c], table[c + 1], table[c + 2], 255]); + } + frames.push(canvas.clone()); + } + b => panic!("unexpected block {b:#x} at {at}"), + } + } + (w, h, frames, delays) + } + + fn frame(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec { + (0..w * h).flat_map(|i| f(i % w, i / w).into_iter().chain([255])).collect() + } + + #[test] + fn few_colours_come_back_exactly_and_frames_keep_their_size() { + let (w, h) = (37, 23); + let a = frame(w, h, |x, y| { + if x < 10 { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + // The second frame moves the red band: only that part changes. + let b = frame(w, h, |x, y| { + if (5..15).contains(&x) { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + let bytes = gif(w, h, &[a.clone(), b.clone(), b.clone()], 5).unwrap(); + let (dw, dh, frames, delays) = decode(&bytes); + assert_eq!((dw, dh, delays), (w, h, vec![5, 5, 5])); + assert_eq!(frames, vec![a, b.clone(), b]); + } + + #[test] + fn a_thin_saturated_line_keeps_its_colour_among_many_shades() { + let (w, h) = (300, 200); + // A grey shading with far more than 256 shades, a red part and a 1-pixel green line. + let f = frame(w, h, |x, y| { + if y == 100 { + [30, 220, 40] + } else if (40..80).contains(&x) && (40..80).contains(&y) { + [200 + (y as u8 % 40), 30, 30] + } else { + let g = 60 + ((x * 180) / w) as u8; + [g, g.saturating_add((y / 4) as u8), g.saturating_add(10)] + } + }); + let (_, _, frames, _) = decode(&gif(w, h, std::slice::from_ref(&f), 4).unwrap()); + let px = |img: &[u8], x: usize, y: usize| img[(y * w + x) * 4..(y * w + x) * 4 + 3].to_vec(); + assert_eq!(px(&frames[0], 150, 100), vec![30, 220, 40], "the line keeps its colour"); + let red = px(&frames[0], 60, 60); + assert!(red[0] > 180 && red[1] < 60 && red[2] < 60, "the part stays red: {red:?}"); + // Elsewhere each channel is close to the original. + let worst = f.chunks(4).zip(frames[0].chunks(4)).map(|(a, b)| (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap()).max().unwrap(); + assert!(worst <= 12, "worst channel error {worst}"); + } + + #[test] + fn bad_input_is_none_not_a_crash() { + assert!(gif(4, 4, &[], 5).is_none()); + assert!(gif(4, 4, &[vec![0; 63]], 5).is_none()); + assert!(gif(0, 4, &[vec![]], 5).is_none()); + assert!(gif(70_000, 1, &[vec![0; 280_000]], 5).is_none()); + assert!(gif(usize::MAX, 2, &[vec![0; 8]], 5).is_none()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0e12a95a..4ccf3315 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ mod analysis; mod backend; mod camera; mod curve_display; +mod gif; pub mod job; pub mod lighting; mod product; @@ -28,6 +29,7 @@ pub use backend::{ }; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; +pub use gif::gif; pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use raster::{Canvas, render, render_png, render_transparent}; pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f7cb7ebe..8601a3bb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -187,7 +187,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides } /// The grid and the origin's axes as the window shows them (the Grid and Origin switches). -fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { +pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() } } diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 523d8c68..3bb4f9c2 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -326,6 +326,34 @@ fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false), + Err(e) => app.set_status(e.to_string(), true), + } + }); +} + /// The explosion at `scale`, computed on a scratch copy. fn explode_pose(app: &SolveApp, scale: f64) -> Result, String> { let mut sc = app.session.scratch(); @@ -532,15 +560,28 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel tracks.remove(i); } row_label(ui, ""); - if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() - && let Some((ji, _, kind)) = moving.first() - { - let unit = if kind.is_angle(0) { "deg" } else { "mm" }; - tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); - } + let mut export = false; + ui.horizontal(|ui| { + if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() + && let Some((ji, _, kind)) = moving.first() + { + let unit = if kind.is_angle(0) { "deg" } else { "mm" }; + tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); + } + export = ui + .button(crate::i18n::tr_label("Export Animation…")) + .on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it")) + .clicked(); + }); ui.end_row(); // Show the pose at this step (again when the step or the keys change). let params = study_params(&app.session, name, study.as_deref(), steps, tracks); + if export { + match ¶ms { + Ok(p) => export_animation(app, p.clone(), name), + Err(e) => *error = Some(e.clone()), + } + } let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -1276,6 +1317,48 @@ mod tests { } } + /// Export Animation saves the study the dialog shows (its keys need not be saved) as a GIF, + /// also when the name is typed without `.gif`, and the design keeps no study. + #[test] + fn export_animation_saves_the_dialogs_study_as_a_gif() { + // Drawn as the CLI draws (the headless device), as scenario shots are. + #[cfg(not(target_arch = "wasm32"))] + if solidcraft_engine::capture::installed().is_none() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer }) + })); + } + let mut h = crate::scenario::Harness::new(); + for (name, x) in [("A", 0), ("B", 30)] { + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("component.create", json!({ "name": name })).unwrap(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + let a = json!({"occurrence": "A:1", "face": [10, 10, 10]}); + h.app.run("joint.create", json!({"type": "revolute", "a": a, "b": {"occurrence": "B:1", "face": [40, 10, 0]}})).unwrap(); + h.app.start("motion.study"); + h.frames(3); + let Some(Kind::Motion(Mo::MotionStudy { name, steps, tracks, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { + panic!("no Motion Study dialog"); + }; + *name = "Swing".into(); + *steps = "4".into(); + *tracks = vec![Track { joint: 0, index: 0, points: vec![(0, "0 deg".into()), (4, "90 deg".into())] }]; + let dir = h.dir.clone(); + h.app.services.pick_save = Some(Box::new(move |name, extensions| { + assert_eq!((name, extensions), ("Swing.gif", &["gif"][..])); + Box::pin(std::future::ready(Some(dir.join("swing").to_string_lossy().into_owned()))) + })); + h.frames(2); + h.step(&json!({"widget": "Export Animation…"})).unwrap(); + h.frames(3); + let gif = std::fs::read(h.dir.join("swing.gif")).unwrap(); + assert!(gif.starts_with(b"GIF89a"), "{:?}", h.app.status); + assert!(h.app.status.as_ref().is_some_and(|s| !s.2), "{:?}", h.app.status); + assert!(h.app.session.doc.assembly.studies.is_empty(), "exporting saves no study"); + } + #[test] fn study_keys_by_joint_id() { let mut s = Session::default(); diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index f3a1de7f..743a30ce 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -122,6 +122,23 @@ study, samples?}`) answers the world points without changing the design, and `motion.trace.keep` with the same parameters (and `name?`) keeps them. A trace takes 2 to 500 samples: 100 by default for a joint, one per step for a study. +**Export Animation…** in the **Motion Study** dialog saves the study as an animated GIF, drawn +from the viewport's camera as the study plays (the keys in the dialog count, even before OK). +`motion.export_animation` does the same for scripts, the CLI and agents, without a window: give a +`study`, or a `joint` with `from` and `to` (and `index?`) to animate a drive, and a `path`. A path +ending in `.gif` gets an animated GIF that loops; any other path is a folder that gets the frames +as `frame-0001.png`, `frame-0002.png`, and so on (made if missing; frames of a longer earlier +export there are removed), for a video tool. The web app writes GIFs only. `samples` is +the number of frames (one per step for a study by default, 100 for a joint), `width` and `height` +their size in pixels (800 × 500 by default, at most 4096 a side), and `fps` the GIF's speed (20 +by default); `grid: false` and `axes: false` leave out the ground grid and the origin's axes. +`camera` takes the reply of the control channel's `ui.camera` (or any of its fields; +the rest come from the view `view.apply` chose, or the home view framing the model). The frames +are drawn by the same renderer as `solidcraft-cli snapshot`, on a copy of the design, which is not +changed. A GIF keeps one palette for all its frames, so colours don't flicker, and stores only +what changed from one frame to the next. Frames are capped: at most 1000, and a GIF holds at most +2^26 pixels of frames (about 160 frames of 800 × 500). + **Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and in the **File** menu) adds a component linked to another saved design, so each part can live in its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index e98a158c..b11b283e 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -171,6 +171,7 @@ cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket cargo run --release -p solidcraft-cli -- eval examples/bracket.json # volume, area, faces… cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png cargo run --release -p solidcraft-cli -- exec solid.box '{"length": 20, "width": 10, "height": 5}' +cargo run --release -p solidcraft-cli -- run mechanism.json --in arm.solidcraft # with a motion.export_animation step: a GIF ``` A script is a list of commands: diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 14562cbd..26d72590 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -63,7 +63,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free - **Each stop:** solve for where the limit is reached, and compare with the value the drive stopped at. Stops are found by bisection, so they land within about 0.01 degrees. - **Links:** b must equal r·a + o exactly, unless that is outside b's limits; then b sits at the limit and the mirror stops following. - **The whole motion:** `motion.play` at a few steps, or one `motion.export`, and compare the poses with the values you planned. -- **Look:** when the server is bridged, `screenshot` after `motion.play` shows the app window at that step. +- **Look:** when the server is bridged, `screenshot` after `motion.play` shows the app window at that step. `motion.export_animation {"study":..., "path":"tour.gif"}` (or `joint`, `from`, `to`) writes the whole motion as an animated GIF without changing the design. ## Save - Drive the joints back to the pose you planned to keep (`motion.play` at step 0, or `joint.drive`), then `save {path}`. diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index aee1fa9b..8ae5dfd4 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -151,4 +151,6 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft ## Showing it moving -Bridged to the app, `motion.play {"study":..., "step":s}` moves the user's viewport, and `screenshot` captures the window at each step. To record outside MCP, drive the app's control channel (`docs/control-protocol.md`): `engine.execute` with `motion.play`, then `ui.screenshot` with a `path`, once per frame. +`motion.export_animation {"study":..., "path":"tour.gif"}` draws the whole motion as an animated GIF in one call, headless or bridged, and changes nothing: `{"joint":..., "from":0, "to":90, "path":...}` animates a drive instead. A path that does not end in `.gif` is a folder for PNG frames (`frame-0001.png`, ...). Pass `camera` (the reply of `ui.camera`, or any of its fields), `width`/`height` (default 800 x 500), `samples` (frames; default the study's steps + 1, or 100), `fps` (default 20), and `grid: false` / `axes: false` for a clean background. The reply is small: `frames`, `bytes`, and `stopped_at` with `stopped_by_loop`/`stopped_by_contact` where the motion first stopped short. + +Bridged to the app, `motion.play {"study":..., "step":s}` moves the user's viewport, and `screenshot` captures the window at that step. From bcacb7066d280cc521f9fc9e4db07bad2c10dd44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:35:58 +0000 Subject: [PATCH 015/149] Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] --- docs/features.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/features.md b/docs/features.md index fc4add7d..34a01b4d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **384 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **386 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (103) @@ -150,10 +150,11 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (17, 1 planned) +## Surfaces (18, 1 planned) | Command | Id | Toolbar | |---|---|---| +| Boundary Surface | `surface.boundary` | SURFACE › CREATE | | Patch | `surface.patch` | SURFACE › CREATE | | Stitch | `surface.stitch` | SURFACE › MODIFY | | Thicken | `surface.thicken` | SURFACE › CREATE | @@ -485,13 +486,14 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | List Faces | `model.faces` | – | | List Threads | `model.threads` | – | -## `motion.*` (7) +## `motion.*` (8) | Command | Id | Toolbar | |---|---|---| | Motion Study | `motion.study` | SOLID › ASSEMBLE | | Play Motion Study | `motion.play` | – | | Export Motion Study | `motion.export` | – | +| Export Motion Animation | `motion.export_animation` | – | | Motion Trace | `motion.trace` | SOLID › ASSEMBLE | | Keep Motion Trace | `motion.trace.keep` | – | | List Motion Studies | `motion.list` | – | From 9194f4be09778c0339a70acc270a8e8778ef6d55 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:26:08 -0400 Subject: [PATCH 016/149] Screenshot of a hidden window: at once, and say so (#1677) (#1707) Thanks @DuBearte. --- crates/mcp/src/tests.rs | 53 +++++++++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 17 +++++++++++-- crates/ui-egui/src/lib.rs | 15 ++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index bdb3c11b..dfa086c2 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -831,6 +831,59 @@ fn bridged_screenshots_with_grid_or_axes_are_model_renders() { let _ = std::fs::remove_dir_all(&dir); } +/// #1677: a hidden window gives no picture. With no `source`, a model render comes back and says +/// it is one (`fallback`); asked for the window, the tool says why there is none, at once. +#[test] +fn a_hidden_window_screenshot_says_so() { + struct Hidden { + calls: Vec, + } + impl crate::backend::Backend for Hidden { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push(method.to_string()); + match method { + "ui.screenshot" => Err("no frame was presented (window hidden?); use ui.render".into()), + "ui.render" => { + let path = params["path"].as_str().ok_or("missing `path`")?; + std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?; + Ok(json!({"path": path, "width": 64, "height": 48})) + } + other => Err(format!("unknown method `{other}`")), + } + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "app".into() + } + } + let dir = std::env::temp_dir().join(format!("solidcraft-mcp-hidden-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("shot.png").to_string_lossy().to_string(); + let shoot = |extra: Value| { + let mut args = json!({"path": path, "width": 64, "height": 48}); + args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let mut app = Hidden { calls: Vec::new() }; + let r = crate::tools::call_tool(&mut app, "screenshot", &args); + (app.calls, r) + }; + // The default: the window first, then a model render that says why. + let (calls, r) = shoot(json!({})); + assert!(r["isError"] != json!(true), "{r}"); + assert_eq!(calls, ["ui.screenshot", "ui.render"]); + let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["source"], "model", "{info}"); + assert!(info["fallback"].as_str().is_some_and(|f| f.contains("window hidden")), "{info}"); + // Asked for the window: an error naming the cause and the way out, and no render. + let (calls, r) = shoot(json!({"source": "window"})); + assert_eq!(r["isError"], json!(true), "{r}"); + let text = r["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("window hidden") && text.contains("source \"model\""), "{text}"); + assert_eq!(calls, ["ui.screenshot"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #530: in a design with many bodies every feature result lists all of them, so a batch reply /// is mostly the same body list once per step. A compact batch reply names only what each step /// added or changed. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 561ecc99..138361d0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -639,11 +639,21 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu } b.call("ui.render", params) }; + let asked_window = str_arg(a, "source") == Some("window"); + let mut fallback = None; let (info, source) = if window { match b.call("ui.screenshot", json!({"path": path})) { Ok(v) => (v, "window"), - // No presented frame (minimised or hidden window): fall back to a model render. - Err(_) => (render(b)?, "model"), + // Asked for the window: say why there is none, so the caller can show it or ask for + // a model render (#1677). + Err(e) if asked_window => { + return Err(format!("screenshot: the window gave no picture ({e}); show the window, or use source \"model\"")); + } + // No presented frame (minimised or hidden window): a model render, and the reply says so. + Err(e) => { + fallback = Some(e); + (render(b)?, "model") + } } } else { (render(b)?, "model") @@ -659,6 +669,9 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu }; let bytes = png.len(); let mut reply = json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes}); + if let Some(why) = fallback { + reply["fallback"] = json!(format!("the window gave no picture ({why}); this is a model render")); + } // What drew a model render (gpu, gpu-software or cpu), and the warning a CPU render carries. for k in ["renderer", "warning"] { if let Some(v) = info.get(k).filter(|v| !v.is_null()) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60aec6bf..54d735f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1445,6 +1445,11 @@ impl SolveApp { control::Outcome::Done(v) => { let _ = reply.send(v); } + // A window that is minimised or covered (another macOS Space) presents no frame: + // say so at once rather than after the deadline (#1677). + control::Outcome::Screenshot { .. } if window_hidden(ctx) => { + let _ = reply.send(json!({"ok": false, "hidden": true, "error": "no frame was presented (window hidden?); use ui.render"})); + } control::Outcome::Screenshot { path } => { self.shot_token += 1; let token = self.shot_token; @@ -1498,8 +1503,10 @@ impl SolveApp { } } let now = now_ms(); + // Hidden while waiting: no frame is coming. + let hidden = window_hidden(ctx); self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { + if now < *deadline && !hidden { return true; } let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); @@ -1508,6 +1515,12 @@ impl SolveApp { } } +/// The window is minimised or fully covered, so it presents no frames (#1677). Unknown counts as +/// shown: the screenshot deadline still ends a wait for a frame that never comes. +fn window_hidden(ctx: &egui::Context) -> bool { + ctx.input(|i| i.viewport().visible()) == Some(false) +} + /// A command's name as people see it (its label), for the status bar. pub fn command_label(id: &str) -> &str { solidcraft_engine::find_command(id).map_or(id, |c| c.label) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebddf4c3..019701a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -144,7 +144,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text | -| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | +| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame: a minimised or fully covered window answers at once with `hidden: true` and an error; use `ui.render`) | | `ui.render` | `path`, `width?`, `height?`, `grid?`, `axes?` | Render of the model with the current camera (no window needed), drawn on the app's GPU device with the viewport's pipelines (the reply's `renderer`: `gpu`, or `gpu-software` on a software adapter), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped. The ground grid and the origin's axes are drawn as the window shows them (`ui.set` `showGrid` and `showOrigin`, `hiddenOrigin`); `grid` and `axes` (booleans) say otherwise for this render | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking | diff --git a/docs/mcp.md b/docs/mcp.md index 4085cd84..3f44efa3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -149,7 +149,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | -| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. | +| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. | | `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). | | `undo`, `redo` | — | Undo / redo one change. | | `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). | From d84d8c3222e4477eab6043bdb666b80d1d467fb7 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:26:14 +0200 Subject: [PATCH 017/149] Joint origin: a circle given by its centre (#393) (#1686) Thanks @SychPL. --- crates/engine/src/cmd/joints.rs | 30 ++++++++++++++++++++--- crates/engine/src/cmd/joints_tests.rs | 34 +++++++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/topo.rs | 28 +++++++++++++++++++++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 4b8fdbfe..1a346467 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -305,8 +305,29 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result Ok(o), + // A circle given by its centre, where no face lies (#393). + Err(e) => match (what == "circle").then(|| circle_centre(s, occurrence, local(world))).flatten() { + Some(snap) => Ok(JointOrigin { occurrence, snap }), + None => Err(bad(cmd, e.to_string())), + }, + } +} + +/// The round end of a cylindrical face of the occurrence's bodies centred at `pick`: a point on +/// its axis there, pointing out of that end (as picking its circular edge gives). +fn circle_centre(s: &Session, occurrence: u64, pick: Vec3) -> Option { + let component = if occurrence == 0 { 0 } else { s.doc.occurrences.iter().find(|o| o.id == occurrence)?.component }; + let st = s.model.state(); + let c = st + .bodies + .iter() + .filter(|b| s.doc.model_body_component(b) == component) + .find_map(|b| solidcraft_doc::kernel::cylinder_end_at(&b.body, pick))?; + let along = (pick - c.axis_point).dot(c.axis); + let (at, dir) = if (along - c.start).abs() <= (c.end - along).abs() { (c.start, -1.0) } else { (c.end, 1.0) }; + Some(Snap::Point { point: c.axis_point + c.axis * at, z: Some(c.axis * dir) }) } pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { @@ -699,7 +720,10 @@ fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { match p.get(key) { Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))), Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())), - Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)), + Some(o @ Value::Object(m)) if m.contains_key("circle") => match origin_param(s, Some(o), cmd)? { + o @ JointOrigin { snap: Snap::CircleCenter { .. }, .. } => Ok(Size::Circle(o)), + _ => Err(bad(cmd, format!("`{key}`: give a point on the round face, not its centre"))), + }, _ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))), } } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index e86571f8..2abeadb5 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -34,6 +34,40 @@ fn leaf(s: &mut Session, name: &str) { run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); } +#[test] +fn circle_given_by_its_centre_takes_that_round_end() { + // #393: `circle` at the centre of a round end, as the parameters document, was refused with + // "no cylindrical face there" (no face lies at a centre). + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 6, "corner": [-15, -10, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Knob"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 0, 6], "axis": [0, 0, 1], "radius": 10, "height": 10, "operation": "new"})); + let r = run( + &mut s, + "joint.create", + json!({"type": "cylindrical", "a": {"occurrence": "Plate", "face": [0, 0, 6]}, "b": {"occurrence": "Knob", "circle": [0, 0, 6]}, "name": "J"}), + ); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + // The knob's bottom circle already sits on the plate's top: its axis stays put (it may turn + // about it, to line up the two frames' x). + for z in [6.0, 16.0] { + assert!(near(place(&s, "Knob", Vec3::new(0.0, 0.0, z)), Vec3::new(0.0, 0.0, z)), "{:?}", place(&s, "Knob", Vec3::new(0.0, 0.0, z))); + } + // The top end's centre: its axis points up, out of the knob. + run(&mut s, "joint.origin", json!({"occurrence": "Knob", "circle": [0, 0, 16], "name": "Top"})); + let top = s.doc.assembly.origins.iter().find(|o| o.name == "Top").map(|o| o.origin.snap.clone()); + assert!( + matches!(top, Some(solidcraft_doc::joints::Snap::Point { point, z: Some(z) }) if near(point, Vec3::new(0.0, 0.0, 16.0)) && near(z, Vec3::Z)), + "{top:?}" + ); + // Off the axis is still refused, and so is a coupling size (it follows a face). + assert!(s.execute("joint.origin", &json!({"occurrence": "Knob", "circle": [3, 0, 6]})).is_err()); + let e = s.execute("joint.screw", &json!({"joint": "J", "pitch": {"occurrence": "Knob", "circle": [0, 0, 6]}})).unwrap_err(); + assert!(e.to_string().contains("not its centre"), "{e}"); +} + #[test] fn revolute_hinge_turns_about_the_knuckle() { let mut s = Session::default(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index daf37d7b..b4907752 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -181,7 +181,7 @@ pub use surfaces::{ sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch, }; pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with}; -pub use topo::{CylinderFace, cylinder_face_at}; +pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 32c5c6aa..60be2b42 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -663,6 +663,32 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { let tol = (size * 1e-4).max(1e-6); let surfs = classify_faces(b, &mesh, nf, tol); let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None }; + Some(cylinder_extent(&mesh, face, axis, ap, r)) +} + +/// The cylindrical face one of whose end circles is centred at `p` (a circle given by its +/// centre, where no face lies), the nearest when several are. +pub fn cylinder_end_at(b: &Body, p: Vec3) -> Option { + let size = b.size(); + let tol = (size * 1e-3).max(1e-3); + let mesh = b.tessellate(tol).ok()?; + let surfs = classify_faces(b, &mesh, b.face_count(), (size * 1e-4).max(1e-6)); + let mut best: Option<(f64, CylinderFace)> = None; + for (face, s) in surfs.iter().enumerate() { + let Surf::Cylinder { axis, p: ap, r } = *s else { continue }; + let c = cylinder_extent(&mesh, u32::try_from(face).ok()?, axis, ap, r); + let v = p - c.axis_point; + let along = v.dot(c.axis); + let off = (v - c.axis * along).len() + (along - c.start).abs().min((c.end - along).abs()); + if off <= tol && best.is_none_or(|(bo, _)| off < bo) { + best = Some((off, c)); + } + } + best.map(|(_, c)| c) +} + +/// The extent along its axis, and the side, of a cylindrical face of a tessellated body. +fn cylinder_extent(mesh: &Mesh, face: u32, axis: Vec3, ap: Vec3, r: f64) -> CylinderFace { let mut lo = f64::INFINITY; let mut hi = f64::NEG_INFINITY; let mut inward = 0.0; @@ -679,5 +705,5 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { inward += n.dot(radial); } } - Some(CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 }) + CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 } } From 838d0e86f221116dfff6b7ec845d0edb0e567d38 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:26:18 +0200 Subject: [PATCH 018/149] Component rename also renames its placements (#1665) (#1685) Thanks @SychPL. --- crates/engine/src/cmd/component.rs | 14 +++++++++++--- crates/engine/src/cmd/occurrence_tests.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ffe5510f..80a4c5ff 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -214,10 +214,18 @@ fn rename(s: &mut Session, p: &Value) -> Result { let id = component(s, p, "component", cmd)?; let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + let d = s.doc_mut(); if id == 0 { - s.doc_mut().name = name.to_string(); - } else if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) { - c.name = name.to_string(); + d.name = name.to_string(); + } else if let Some(c) = d.components.iter_mut().find(|c| c.id == id) { + let old = std::mem::replace(&mut c.name, name.to_string()); + // Placements still named after the component ("Old:2") follow it (#1665); others keep theirs. + for o in d.occurrences.iter_mut().filter(|o| o.component == id) { + let k = o.name.strip_prefix(old.as_str()).and_then(|r| r.strip_prefix(':')); + if let Some(k) = k.filter(|k| !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit())) { + o.name = format!("{name}:{k}"); + } + } } Ok(json!({"component": id, "name": name})) } diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 31a1bed8..c88c7f8c 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -66,6 +66,22 @@ fn instance_selection_survives_rename_move_capture_and_serde() { assert_eq!(roundtrip, s.selection); } +#[test] +fn component_rename_carries_into_placement_names() { + // #1665: the browser row shows the placement's name, so a rename has to reach it. + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + let third = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [40,0,0]}))["occurrence"].as_u64().unwrap(); + let name = |s: &Session, id: u64| s.doc.occurrences.iter().find(|o| o.id == id).unwrap().name.clone(); + assert_eq!(name(&s, second), "Block:2"); + // A placement named otherwise (an import's own name) keeps it. + s.doc_mut().occurrences.iter_mut().find(|o| o.id == third).unwrap().name = "Blockade:3".into(); + run(&mut s, "component.rename", json!({"component": component, "name": "box"})); + assert_eq!([name(&s, first), name(&s, second), name(&s, third)], ["box:1", "box:2", "Blockade:3"]); + assert_eq!(run(&mut s, "component.list", json!({}))["components"][0]["children"][0]["name"], "box:1"); +} + #[test] fn instance_selection_delete_removes_only_selected_instance() { let mut s = Session::default(); From dd5278a4021c8e518175c6c0ae8aef5ac6ffeb2d Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:26:23 -0400 Subject: [PATCH 019/149] solid.hole: position takes length expressions and follows them (#1678) (#1683) Thanks @DuBearte. --- crates/derive/src/lib.rs | 6 ++-- crates/doc/src/document.rs | 22 ++++++++++++ crates/doc/src/eval.rs | 31 +++++++++++----- .../pending/hole-position-expressions.rs | 5 +++ crates/engine/src/cmd/features.rs | 33 +++++++++++++++-- crates/engine/src/tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 11 ++++-- skills/solidcraft-modeling/reference.md | 2 +- 8 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 crates/doc/src/format/pending/hole-position-expressions.rs diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 74d4ff4e..4fb8e1a7 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -21,7 +21,8 @@ //! `|i: usize, n: usize| -> &'static str`; it may read the other fields; //! - `pinned`: a face pick that stays where it is when the feature moves; //! `pick_if = cond`: a face pick when `cond` holds, else a position; -//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs); +//! - `placement`: a primitive's placement point, or an optional one (its X, Y and Z expressions +//! are not inputs); //! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection; //! - `after = field`: visit this field right after `field` instead of in declaration order (where //! the parameter names already given need it); @@ -162,7 +163,8 @@ fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens { #w::Each::<#w::Vec3>::#each(#b, &mut |p| f(if #pick { #w::Geo::Pick(p) } else { #w::Geo::Pos(p) })); } } - Some(Geo::Placement) => quote! { f(#w::Geo::Placement(#b)); }, + // A point or an optional one (a hole's `at`, #1678). + Some(Geo::Placement) => quote! { #w::Each::::#each(#b, &mut |p| f(#w::Geo::Placement(p))); }, Some(Geo::Profiles { .. }) => { let (id, when) = (pre(b, "sketch"), pre(b, "when")); quote! { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 97780eb4..00523f8e 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -662,6 +662,12 @@ pub enum FeatureKind { Hole { #[walk(pick_if = points.is_none())] position: FacePick, + /// The position as length expressions, when it was given that way (#1678): the hole is + /// drilled where they evaluate, and follows the parameters. `position` keeps the point + /// they gave when the hole was made. + #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_point_expr")] + #[walk(placement)] + at: Option, /// Drilling direction (into the material). direction: Dir, #[walk(length = "Diameter")] @@ -1935,6 +1941,22 @@ mod point_expr { } } +/// [`point_expr`] for an optional point. +mod opt_point_expr { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + struct Wrap(#[serde(with = "super::point_expr")] super::PointExpr); + + pub fn serialize(p: &Option, s: S) -> Result { + p.as_ref().map(|p| Wrap(p.clone())).serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.map(|w| w.0)) + } +} + impl FeatureKind { /// A sheet metal feature (its result depends on the sheet metal rules). pub fn is_plastic(&self) -> bool { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 40cde9bd..60ef47f8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2300,8 +2300,8 @@ fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 { /// Where a hole feature drills (each on the surface) and in which direction: at its sketch /// points perpendicular to the sketch (into the material), else at its position. -fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { - let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; +fn hole_spots(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { + let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; let (spots, dir) = match points { Some(sp) => { let ss = @@ -2317,7 +2317,7 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { } None => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; - let at = hole_on_surface(st, position.0, dir); + let at = hole_on_surface(st, hole_position(vals, position, at)?, dir); let dir = hole_dir_into_face(st, at, dir); return Ok((vec![hole_on_surface(st, at, dir)], dir)); } @@ -2351,8 +2351,8 @@ fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option, depth } /// A hole's spots as given (not yet moved onto the surface) and its direction. -fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { - let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; +fn hole_spots_raw(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { + let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; match points { Some(sp) => { let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?; @@ -2364,16 +2364,29 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { } None => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; - Ok((vec![position.0], hole_dir_into_face(st, hole_on_surface(st, position.0, dir), dir))) + let p = hole_position(vals, position, at)?; + Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir), dir))) } } } +/// Where a hole placed by position is drilled: its `at` expressions evaluated, so it follows the +/// parameters (#1678), else the point it was given. +fn hole_position(vals: &BTreeMap, position: &crate::FacePick, at: &Option) -> Result { + let Some([x, y, z]) = at else { return Ok(position.0) }; + let p = + Vec3::new(Document::eval_in(vals, x, Kind::Length)?, Document::eval_in(vals, y, Kind::Length)?, Document::eval_in(vals, z, Kind::Length)?); + if ![p.x, p.y, p.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { + return Err(DocError::Invalid("the hole's position is out of range".into())); + } + Ok(p) +} + /// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a /// straight cylinder (see [`hole_tools_and_prisms`]). fn hole_feature_tools(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result)>> { let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; - let (spots, dir) = hole_spots(st, f)?; + let (spots, dir) = hole_spots(vals, st, f)?; if spots.is_empty() || spots.len() > 10_000 { return Err(DocError::Invalid("a hole needs 1…10000 points".into())); } @@ -3755,7 +3768,7 @@ fn replay( // A hole is drilled again at each copy of its spot (found on the surface there), so // a blind hole's depth counts from the face at the copy, not from the bottom of the // original hole under the original spot. - let (spots, dir) = hole_spots_raw(st, src)?; + let (spots, dir) = hole_spots_raw(vals, st, src)?; for (k, m) in mats.iter().enumerate() { let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; for p in &spots { @@ -4208,7 +4221,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: apply_traced(st, f, tools, *operation, &crate::plain(targets)) } FeatureKind::Hole { thread, diameter, .. } => { - let (spots, dir) = hole_spots(st, f)?; + let (spots, dir) = hole_spots(vals, st, f)?; let tools = hole_feature_tools(vals, st, f)?; let handled = sheet_hole_cut(st, f, &tools, warning)?; let tools: Vec = tools.into_iter().map(|(tool, _)| tool).collect(); diff --git a/crates/doc/src/format/pending/hole-position-expressions.rs b/crates/doc/src/format/pending/hole-position-expressions.rs new file mode 100644 index 00000000..8c5257ab --- /dev/null +++ b/crates/doc/src/format/pending/hole-position-expressions.rs @@ -0,0 +1,5 @@ +//! A Hole placed by position can keep that position as length expressions (`at`: `[x, y, z]`, +//! numbers as numbers, expressions as strings), and is drilled where they evaluate, so it follows +//! the design's parameters (#1678). `position` still holds the point it was made at. Older designs +//! need no transformation: a hole without `at` is drilled at `position` as before; 0.13.x asks for +//! an update instead of drilling a moved hole at its old point. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fa07dd15..bbd35cae 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("hole") .key("H") - .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), + .params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), CommandSpec::new("solid.pipe", "Pipe", pipe) .at("SOLID", "CREATE") .icon("pipe") @@ -1633,10 +1633,36 @@ fn hole(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)"))); } - let position = match (&points, p.get("position").and_then(vec3)) { + // A position given as length expressions (#1678), say `["leg_h*0.4", "width/2", "t"]`: kept, + // so the hole follows the parameters; drilled where they evaluate now. + let at: Option = match p.get("position").and_then(Value::as_array) { + Some(a) if points.is_none() && a.len() == 3 && a.iter().any(Value::is_string) => { + let mut coords = Vec::with_capacity(3); + for v in a { + let e = match v { + Value::String(x) => x.trim().to_string(), + Value::Number(n) => n.to_string(), + _ => return Err(bad(cmd, "`position` must be [x, y, z]: numbers or length expressions")), + }; + check_expr(s, &e, Kind::Length, cmd, "position")?; + coords.push(solidcraft_doc::Expr::new(e)); + } + let [x, y, z]: [solidcraft_doc::Expr; 3] = coords.try_into().map_err(|_| bad(cmd, "`position` must be [x, y, z]"))?; + Some([x, y, z]) + } + _ => None, + }; + let at_value = match &at { + Some([x, y, z]) => { + let c = |e: &solidcraft_doc::Expr| s.doc.eval(e, Kind::Length).map_err(|e| bad(cmd, format!("`position`: {e}"))); + Some(Vec3::new(c(x)?, c(y)?, c(z)?)).filter(|v| v.is_finite()) + } + None => None, + }; + let position = match (&points, at_value.or_else(|| p.get("position").and_then(vec3))) { (Some((_, first, _)), None) => *first, (_, Some(x)) => x, - (None, None) => return Err(bad(cmd, "`position` must be [x, y, z] (or give `sketch` and `points`)")), + (None, None) => return Err(bad(cmd, "`position` must be [x, y, z], numbers or length expressions (or give `sketch` and `points`)")), }; let direction = match (p.get("direction").and_then(vec3), &points) { (None, Some((_, _, d))) => *d, @@ -1706,6 +1732,7 @@ fn hole(s: &mut Session, p: &Value) -> Result { &p, FeatureKind::Hole { position: FacePick(position), + at, direction: Dir(direction), diameter, depth, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c5c419f8..23e33e46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1217,6 +1217,41 @@ fn extrude_auto_operation_follows_the_to_object() { assert_eq!(extrude_reach(&s, &json!({"face": [20, 15, 20], "distance": 5, "direction": "negative"})), Some(-5.0)); } +/// #1678: a hole's `position` takes length expressions, keeps them and follows the parameters: a +/// hole on a plate's middle line stays there when the plate gets wider. A position of plain +/// numbers is stored as before, with no `at`. +#[test] +fn a_hole_position_of_expressions_follows_the_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "width", "expression": "30 mm"})); + run(&mut s, "parameters.add", json!({"name": "t", "expression": "4 mm"})); + run(&mut s, "solid.box", json!({"length": 100, "width": "width", "height": "t", "body_name": "Plate"})); + run(&mut s, "solid.hole", json!({"position": ["40", "width/2", "t"], "clearance": "M5", "name": "Middle"})); + run(&mut s, "solid.hole", json!({"position": [80, 10, 4], "clearance": "M5", "name": "Fixed"})); + let centre_y = |s: &Session| { + let st = s.model.state(); + solidcraft_kernel::measure(&st.body("Plate").unwrap().body).unwrap().centroid.y + }; + let hole = |s: &Session, name: &str| -> Value { + let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + saved["features"].as_array().and_then(|f| f.iter().find(|f| f["name"] == name)).cloned().unwrap() + }; + assert_eq!(hole(&s, "Middle")["at"], json!([40.0, "width/2", "t"]), "the expressions are kept"); + assert!(hole(&s, "Fixed").get("at").is_none(), "plain numbers are stored as before"); + // The fixed hole sits off the middle line, so it alone moves the centre; widen the plate. + let before = centre_y(&s); + run(&mut s, "parameters.change", json!({"name": "width", "expression": "40 mm"})); + let after = centre_y(&s); + // The middle hole is at y = 20 now (the plate's middle); the fixed one stays at y = 10. With + // the middle hole left at y = 15 the centre would sit lower still. + let plate = 100.0 * 40.0 * 4.0; + let hole_v = std::f64::consts::PI * (5.5f64 / 2.0).powi(2) * 4.0; + let want = (plate * 20.0 - hole_v * 20.0 - hole_v * 10.0) / (plate - 2.0 * hole_v); + assert!((after - want).abs() < 1e-3, "centre y {after}, want {want} (was {before})"); + // A bad expression is refused. + assert!(s.execute("solid.hole", &json!({"position": ["40", "nope/2", "t"], "diameter": 3})).is_err()); +} + /// Additive extrusions default to joining even when only a profile side touches material. #[test] fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 10219146..d1385ca2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4109,7 +4109,8 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S continue; } taken.extend(crate::viewport::grouped_selection(s, x)); - places.extend(face_point(x).map(|p| json!({ "position": p }))); + let kept = d.extra.get("position_exprs").filter(|k| face_point(x).is_some_and(|p| p == k["point"])).map(|k| k["at"].clone()); + places.extend(face_point(x).map(|p| json!({ "position": kept.unwrap_or(p) }))); } } let mut out = Vec::new(); @@ -4697,7 +4698,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, to } => { let mut d = start("solid.hole")?; let mut k = hole_defaults(); if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k { @@ -4745,6 +4746,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option = at.iter().map(|e| e.as_str()).collect(); + d.extra.insert("position_exprs".into(), json!({"at": exprs, "point": pt3(position.0)})); + } } } if to.is_some() { diff --git a/skills/solidcraft-modeling/reference.md b/skills/solidcraft-modeling/reference.md index 7cab578e..27624cdf 100644 --- a/skills/solidcraft-modeling/reference.md +++ b/skills/solidcraft-modeling/reference.md @@ -75,7 +75,7 @@ Distances are unsigned. Draw the geometry on the correct side first, and the dim | `sketch.slot.overall` / `sketch.slot.center_to_center` | `p0`, `p1` (overall ends / arc centres), `width` | Ids and dof are in the "Ids" section above. | | `solid.fillet` / `solid.chamfer` | `edges:[[x,y,z]]`, `radius` / `distance` | The pick snaps to the **nearest** edge, with no distance limit and no error, so check that the face count changed. One pick takes the whole tangent chain: a rounded rim needs 1 point, not 8. | | `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove, or its name from `body_topology`. Fillet first, except an edge of a removed face (see Order in `SKILL.md`). | -| `solid.hole` | `position:[x,y,z]` on a face + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point. | +| `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point. | | `solid.pattern.rectangular` | `features`, `dir1`, `count1`, `spacing1` (+ `dir2`, `count2`, `spacing2`) | Spacing takes expressions and follows parameters. | | `solid.pattern.circular` | `features`, `axis` (`"Z"` or `{origin, dir}`), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. | | `solid.mirror` | `features:[names]`, `plane` (`XY`/`XZ`/`YZ`, a construction plane, or `{origin, normal}` whose origin may use length expressions) | Follows parameters. Mirror and pattern features cannot be mirrored or patterned again. | From 737c9358d070c233c15cbe8eff69c7d04bfa4d2b Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:26:28 +0200 Subject: [PATCH 020/149] Add three-axis milling with independent CAM and Fusion validation (#1682) Thanks @CypherNaught-0x. --- .github/workflows/ci.yml | 11 + ATTRIBUTION.md | 2 +- Cargo.lock | 192 ++- Cargo.toml | 1 + ROADMAP.md | 16 +- assets/icons/ui/source/icons.rs | 13 + assets/locales/en.json | 20 + assets/locales/ja.json | 20 + assets/locales/zh.json | 20 + assets/styles/ui/palettes.rs | 22 + crates/cam/Cargo.toml | 17 + crates/cam/src/collision.rs | 483 ++++++++ crates/cam/src/compare.rs | 489 ++++++++ crates/cam/src/corpus.rs | 388 +++++++ crates/cam/src/corpus/channel.rs | 98 ++ crates/cam/src/corpus/extended.rs | 220 ++++ crates/cam/src/corpus/exterior.rs | 84 ++ crates/cam/src/corpus/through.rs | 94 ++ crates/cam/src/coverage.rs | 215 ++++ crates/cam/src/dimensions.rs | 554 +++++++++ crates/cam/src/generate.rs | 680 +++++++++++ crates/cam/src/hashing.rs | 150 +++ crates/cam/src/lib.rs | 51 + crates/cam/src/model.rs | 523 +++++++++ crates/cam/src/nc.rs | 712 ++++++++++++ crates/cam/src/playback.rs | 73 ++ crates/cam/src/region.rs | 233 ++++ crates/cam/src/spatial.rs | 65 ++ crates/cam/src/stock_mesh.rs | 728 ++++++++++++ crates/cam/src/surface.rs | 308 +++++ crates/cam/src/verify.rs | 644 ++++++++++ crates/cam/tests/machine_state.rs | 69 ++ crates/cam/tests/milling_direction.rs | 112 ++ crates/cam/tests/nc_incremental.rs | 116 ++ crates/doc/Cargo.toml | 1 + crates/doc/src/document.rs | 10 + .../doc/src/format/pending/manufacturing.rs | 4 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/cam.rs | 603 ++++++++++ crates/engine/src/cmd/cam_holes_tests.rs | 201 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 6 + crates/engine/src/manufacturing.rs | 461 ++++++++ crates/engine/src/manufacturing/exports.rs | 119 ++ crates/engine/src/manufacturing/holes.rs | 179 +++ crates/engine/src/manufacturing/snapshot.rs | 422 +++++++ crates/io/Cargo.toml | 1 + crates/io/src/cam.rs | 62 + crates/io/src/cam/incremental.rs | 276 +++++ crates/io/src/lib.rs | 1 + crates/kernel/src/body.rs | 6 + crates/kernel/src/body_verification.rs | 58 + crates/kernel/src/body_verification_tests.rs | 94 ++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/surface_kind.rs | 15 + crates/ui-egui/src/colour_vision_tests.rs | 15 +- crates/ui-egui/src/context_menu.rs | 29 +- crates/ui-egui/src/control.rs | 31 +- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/documents.rs | 3 + crates/ui-egui/src/file_ui_tests.rs | 108 ++ crates/ui-egui/src/lib.rs | 38 +- crates/ui-egui/src/manufacturing.rs | 1033 +++++++++++++++++ crates/ui-egui/src/manufacturing/scheduler.rs | 96 ++ crates/ui-egui/src/manufacturing/view.rs | 385 ++++++ crates/ui-egui/src/theme.rs | 6 + crates/ui-egui/src/toolbar.rs | 27 +- crates/ui-egui/src/viewport.rs | 59 +- crates/ui-egui/tests/cam_workflow.rs | 99 ++ docs/cam-acceptance.md | 102 ++ docs/cam-crates.md | 113 ++ docs/cam-next-milestone.md | 92 ++ docs/cam.md | 598 ++++++++++ docs/control-protocol.md | 2 +- vendor/README.md | 6 + vendor/truck-meshalgo/Cargo.toml | 2 +- .../src/tessellation/boundary_index.rs | 206 ++++ vendor/truck-meshalgo/src/tessellation/mod.rs | 18 + .../src/tessellation/verification.rs | 876 ++++++++++++++ xtask/Cargo.toml | 1 + xtask/src/cam.rs | 352 ++++++ xtask/src/cam/reference.rs | 104 ++ xtask/src/cam/tests.rs | 173 +++ xtask/src/layers.rs | 1 + xtask/src/main.rs | 7 + 85 files changed, 14487 insertions(+), 43 deletions(-) create mode 100644 crates/cam/Cargo.toml create mode 100644 crates/cam/src/collision.rs create mode 100644 crates/cam/src/compare.rs create mode 100644 crates/cam/src/corpus.rs create mode 100644 crates/cam/src/corpus/channel.rs create mode 100644 crates/cam/src/corpus/extended.rs create mode 100644 crates/cam/src/corpus/exterior.rs create mode 100644 crates/cam/src/corpus/through.rs create mode 100644 crates/cam/src/coverage.rs create mode 100644 crates/cam/src/dimensions.rs create mode 100644 crates/cam/src/generate.rs create mode 100644 crates/cam/src/hashing.rs create mode 100644 crates/cam/src/lib.rs create mode 100644 crates/cam/src/model.rs create mode 100644 crates/cam/src/nc.rs create mode 100644 crates/cam/src/playback.rs create mode 100644 crates/cam/src/region.rs create mode 100644 crates/cam/src/spatial.rs create mode 100644 crates/cam/src/stock_mesh.rs create mode 100644 crates/cam/src/surface.rs create mode 100644 crates/cam/src/verify.rs create mode 100644 crates/cam/tests/machine_state.rs create mode 100644 crates/cam/tests/milling_direction.rs create mode 100644 crates/cam/tests/nc_incremental.rs create mode 100644 crates/doc/src/format/pending/manufacturing.rs create mode 100644 crates/engine/src/cmd/cam.rs create mode 100644 crates/engine/src/cmd/cam_holes_tests.rs create mode 100644 crates/engine/src/manufacturing.rs create mode 100644 crates/engine/src/manufacturing/exports.rs create mode 100644 crates/engine/src/manufacturing/holes.rs create mode 100644 crates/engine/src/manufacturing/snapshot.rs create mode 100644 crates/io/src/cam.rs create mode 100644 crates/io/src/cam/incremental.rs create mode 100644 crates/kernel/src/body_verification.rs create mode 100644 crates/kernel/src/body_verification_tests.rs create mode 100644 crates/ui-egui/src/file_ui_tests.rs create mode 100644 crates/ui-egui/src/manufacturing.rs create mode 100644 crates/ui-egui/src/manufacturing/scheduler.rs create mode 100644 crates/ui-egui/src/manufacturing/view.rs create mode 100644 crates/ui-egui/tests/cam_workflow.rs create mode 100644 docs/cam-acceptance.md create mode 100644 docs/cam-crates.md create mode 100644 docs/cam-next-milestone.md create mode 100644 docs/cam.md create mode 100644 vendor/truck-meshalgo/src/tessellation/boundary_index.rs create mode 100644 vendor/truck-meshalgo/src/tessellation/verification.rs create mode 100644 xtask/src/cam.rs create mode 100644 xtask/src/cam/reference.rs create mode 100644 xtask/src/cam/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b523826..7d1f990b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: run: cargo xtask assets - name: Licences run: cargo xtask licences --check + - name: Analytic CAM acceptance + run: cargo xtask cam-validate - name: Dependency layers run: cargo xtask layers - name: Web app offline use and updates (headless Chrome) @@ -210,6 +212,7 @@ jobs: # would skip for want of an adapter fails instead. SOLIDCRAFT_GPU: software SOLIDCRAFT_REQUIRE_GPU: "1" + CAM_UI_EVIDENCE: ${{ github.workspace }}/target/cam-ui-evidence steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install a software Vulkan driver (lavapipe) @@ -248,6 +251,14 @@ jobs: cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ --profile ci --ignore-default-filter \ -E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))" + - name: Upload original CAM review images + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: cam-ui-evidence-${{ matrix.shard }} + path: target/cam-ui-evidence + if-no-files-found: ignore + retention-days: 14 - name: Upload test timings if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6221864b..663095e2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -176,7 +176,7 @@ copied into SolidCraft. | Directory | Source | Licence | Changes | |---|---|---|---| -| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | +| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: fixed-tolerance CAM verification path and conservative boundary index; bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) | | vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) | diff --git a/Cargo.lock b/Cargo.lock index 684cb308..30f3b62d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.5.0" @@ -752,6 +761,18 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cavalier_contours" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e06038a969c5e4d93565f12b044814d3b1a48d50c4ef868d588e79956fea51" +dependencies = [ + "ahash", + "num-traits", + "smallvec", + "static_aabb2d_index", +] + [[package]] name = "cbc" version = "0.1.2" @@ -800,7 +821,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" dependencies = [ - "approx", + "approx 0.4.0", "num-traits", "serde", ] @@ -1166,6 +1187,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -1327,6 +1354,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1690,6 +1726,28 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.33.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37bfe73dc8ec21f54d181e5a60554327ec3f1c600c455a75ccbfc65d2106a6c4" +dependencies = [ + "approx 0.5.1", + "libm", +] + +[[package]] +name = "glamx" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d74079318e3a077bd3d15faa90fde24fa37bb9f157f92f853e6019a15fd413" +dependencies = [ + "approx 0.5.1", + "glam", + "num-traits", + "simba", +] + [[package]] name = "glifo" version = "0.2.0" @@ -1867,6 +1925,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1887,6 +1954,16 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2107,6 +2184,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2574,6 +2653,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3026,6 +3116,34 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parry3d-f64" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb9e04261f1783d8f66f36767b2b73132a63c9b30b12a5ed47e1faa240f7591" +dependencies = [ + "approx 0.5.1", + "arrayvec", + "bitflags 2.13.2", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash", + "glamx", + "hashbrown 0.17.1", + "indexmap", + "log", + "num-derive", + "num-traits", + "ordered-float 5.5.0", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 2.0.21", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3558,6 +3676,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3685,6 +3814,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3915,6 +4053,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7" +dependencies = [ + "approx 0.5.1", + "libm", + "num-complex", + "num-traits", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4078,6 +4229,20 @@ dependencies = [ "winit", ] +[[package]] +name = "solidcraft-cam" +version = "0.14.0" +dependencies = [ + "cavalier_contours", + "parry3d-f64", + "robust", + "serde", + "serde_json", + "sha2 0.10.9", + "solidcraft-geom", + "thiserror 2.0.21", +] + [[package]] name = "solidcraft-cli" version = "0.14.0" @@ -4106,6 +4271,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "solidcraft-cam", "solidcraft-derive", "solidcraft-geom", "solidcraft-kernel", @@ -4122,6 +4288,7 @@ dependencies = [ "log", "serde", "serde_json", + "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-io", @@ -4169,6 +4336,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-kernel", @@ -4293,6 +4461,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_aabb2d_index" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a753368e4dd146a347d0ea692f0f8a37df18df7eee5659d36dd75eb287c6454" +dependencies = [ + "num-traits", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4948,7 +5125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.5", "scoped-tls", "smallvec", @@ -5310,6 +5487,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -5755,6 +5942,7 @@ version = "0.14.0" dependencies = [ "serde_json", "sha2 0.10.9", + "solidcraft-cam", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 080e0184..fa7dda4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/bherbruck/solidcraft" [workspace.dependencies] # internal +solidcraft-cam = { path = "crates/cam" } solidcraft-geom = { path = "crates/geom" } solidcraft-sketch = { path = "crates/sketch" } solidcraft-kernel = { path = "crates/kernel" } diff --git a/ROADMAP.md b/ROADMAP.md index 67e1ba71..11aba9cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,21 @@ In scope, in priority order: 5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit. Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D -PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. +PCB / Package, Render, Animation, Simulation, Drawings. + +## Milling + +The first Manufacture workflow implements rectangular stock, setups and fixtures, +flat mills and drills, facing, contouring, pockets with islands and simple/peck drilling. +It includes generation, playback, conservative stock/collision verification and five +exports through shared commands. The reference post is not qualified for a physical +controller. See [implementation and validation](docs/cam.md), +[acceptance evidence](docs/cam-acceptance.md) and [dependency assessment](docs/cam-crates.md). + +[Adaptive clearing and 3D roughing/finishing](docs/cam-next-milestone.md) are the next milestone. They require their +own analytic fixtures, independent engagement/surface checks and complete local Fusion +comparisons at the existing dimensional budgets before parity is declared. Indexed and +multi-axis machining follow later; turning, additive and inspection remain excluded. ## Milestones diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 2f342a8c..4af4b117 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -142,6 +142,19 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) }; let a = pen.accent; match name { + "cam_path" => { + pen.closed(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], ink); + pen.line(&[(5.0, 7.0), (18.0, 7.0), (18.0, 11.0), (6.0, 11.0), (6.0, 15.0), (18.0, 15.0)], a); + } + "cam_generate" => { + pen.line(&[(3.0, 5.0), (10.0, 5.0), (10.0, 11.0), (3.0, 11.0), (3.0, 18.0), (10.0, 18.0)], ink); + pen.poly(&[(14.0, 5.0), (22.0, 12.0), (14.0, 19.0)], a, a); + } + "cam_verify" => { + pen.iso_box(2.0, 9.0, 9.0, 6.0, 7.0); + pen.line(&[(12.0, 16.0), (15.0, 19.0), (22.0, 9.0)], a); + } + "sketch" => { pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink); pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a); diff --git a/assets/locales/en.json b/assets/locales/en.json index b2238047..fad39f95 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "Rename Group", "cmd.browser_ungroup": "Ungroup", "cmd.browser_visibility": "Show/Hide", + "cmd.cam_boundary_from_profile": "Use Sketch Profile", + "cmd.cam_delete": "Delete Manufacturing Item", + "cmd.cam_export": "Export CAM Artifact", + "cmd.cam_export_result": "Collect CAM Export", + "cmd.cam_generate": "Generate Toolpaths", + "cmd.cam_holes_from_selection": "Use Selected Holes", + "cmd.cam_inspect": "Inspect Manufacturing", + "cmd.cam_job_cancel": "Cancel CAM Job", + "cmd.cam_job_status": "CAM Job Status", + "cmd.cam_operation_order": "Reorder Operations", + "cmd.cam_operation_put": "Milling Operation", + "cmd.cam_post": "Post Reference G-code", + "cmd.cam_program_put": "NC Program", + "cmd.cam_setup_put": "Milling Setup", + "cmd.cam_simulate": "Simulate Toolpaths", + "cmd.cam_tool_put": "Tool Library", "cmd.canvas_calibrate": "Calibrate Canvas", "cmd.canvas_decal": "Decal", "cmd.canvas_delete": "Delete Canvas", @@ -1113,6 +1129,7 @@ "ui.heading_design": "DESIGN", "ui.heading_draft": "DRAFT", "ui.heading_edit": "EDIT", + "ui.heading_export": "EXPORT", "ui.heading_extrude": "EXTRUDE", "ui.heading_fillet": "FILLET", "ui.heading_finish_sketch": "FINISH SKETCH", @@ -1125,6 +1142,7 @@ "ui.heading_make": "MAKE", "ui.heading_measure": "MEASURE", "ui.heading_mesh": "MESH", + "ui.heading_milling": "MILLING", "ui.heading_mirror": "MIRROR", "ui.heading_modify": "MODIFY", "ui.heading_move": "MOVE", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "SURFACE", "ui.heading_sweep": "SWEEP", "ui.heading_thread": "THREAD", + "ui.heading_toolpaths": "TOOLPATHS", "ui.heading_top": "TOP", "ui.heading_torus": "TORUS", "ui.heading_utilities": "UTILITIES", "ui.heading_utility": "UTILITY", + "ui.heading_verify": "VERIFY", "ui.help": "Help", "ui.hide": "Hide", "ui.hideAxes": "Hide Axes", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 9086717a..f51ed2e5 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "グループの名前を変更", "cmd.browser_ungroup": "グループ解除", "cmd.browser_visibility": "表示/非表示", + "cmd.cam_boundary_from_profile": "スケッチプロファイルを使用", + "cmd.cam_delete": "加工項目を削除", + "cmd.cam_export": "CAM 成果物をエクスポート", + "cmd.cam_export_result": "CAM 出力を取得", + "cmd.cam_generate": "工具経路を生成", + "cmd.cam_holes_from_selection": "選択した穴を使用", + "cmd.cam_inspect": "加工を確認", + "cmd.cam_job_cancel": "CAM ジョブをキャンセル", + "cmd.cam_job_status": "CAM ジョブの状態", + "cmd.cam_operation_order": "加工順序を変更", + "cmd.cam_operation_put": "切削加工", + "cmd.cam_post": "参照 G コードを出力", + "cmd.cam_program_put": "NC プログラム", + "cmd.cam_setup_put": "切削セットアップ", + "cmd.cam_simulate": "工具経路をシミュレーション", + "cmd.cam_tool_put": "工具ライブラリ", "cmd.canvas_calibrate": "キャンバスの尺度を設定", "cmd.canvas_decal": "デカール", "cmd.canvas_delete": "キャンバスを削除", @@ -1113,6 +1129,7 @@ "ui.heading_design": "デザイン", "ui.heading_draft": "勾配", "ui.heading_edit": "編集", + "ui.heading_export": "出力", "ui.heading_extrude": "押し出し", "ui.heading_fillet": "フィレット", "ui.heading_finish_sketch": "スケッチを終了", @@ -1125,6 +1142,7 @@ "ui.heading_make": "製造", "ui.heading_measure": "計測", "ui.heading_mesh": "メッシュ", + "ui.heading_milling": "切削", "ui.heading_mirror": "ミラー", "ui.heading_modify": "修正", "ui.heading_move": "移動", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "サーフェス", "ui.heading_sweep": "スイープ", "ui.heading_thread": "ねじ", + "ui.heading_toolpaths": "工具経路", "ui.heading_top": "上面", "ui.heading_torus": "トーラス", "ui.heading_utilities": "ユーティリティ", "ui.heading_utility": "ユーティリティ", + "ui.heading_verify": "検証", "ui.help": "ヘルプ", "ui.hide": "非表示", "ui.hideAxes": "軸を非表示", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index e9acd94e..885028db 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "重命名组", "cmd.browser_ungroup": "取消编组", "cmd.browser_visibility": "显示/隐藏", + "cmd.cam_boundary_from_profile": "使用草图轮廓", + "cmd.cam_delete": "删除制造项目", + "cmd.cam_export": "导出 CAM 工件", + "cmd.cam_export_result": "获取 CAM 导出", + "cmd.cam_generate": "生成刀具路径", + "cmd.cam_holes_from_selection": "使用选定孔", + "cmd.cam_inspect": "检查制造", + "cmd.cam_job_cancel": "取消 CAM 作业", + "cmd.cam_job_status": "CAM 作业状态", + "cmd.cam_operation_order": "重新排序操作", + "cmd.cam_operation_put": "铣削操作", + "cmd.cam_post": "输出参考 G 代码", + "cmd.cam_program_put": "NC 程序", + "cmd.cam_setup_put": "铣削设置", + "cmd.cam_simulate": "模拟刀具路径", + "cmd.cam_tool_put": "刀具库", "cmd.canvas_calibrate": "校准画布", "cmd.canvas_decal": "贴花", "cmd.canvas_delete": "删除画布", @@ -1113,6 +1129,7 @@ "ui.heading_design": "设计", "ui.heading_draft": "拔模", "ui.heading_edit": "编辑", + "ui.heading_export": "导出", "ui.heading_extrude": "拉伸", "ui.heading_fillet": "圆角", "ui.heading_finish_sketch": "完成草图", @@ -1125,6 +1142,7 @@ "ui.heading_make": "制作", "ui.heading_measure": "测量", "ui.heading_mesh": "网格", + "ui.heading_milling": "铣削", "ui.heading_mirror": "镜像", "ui.heading_modify": "修改", "ui.heading_move": "移动", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "曲面", "ui.heading_sweep": "扫掠", "ui.heading_thread": "螺纹", + "ui.heading_toolpaths": "刀具路径", "ui.heading_top": "上视", "ui.heading_torus": "圆环体", "ui.heading_utilities": "工具", "ui.heading_utility": "实用工具", + "ui.heading_verify": "验证", "ui.help": "帮助", "ui.hide": "隐藏", "ui.hideAxes": "隐藏轴", diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 2236e5ff..5c697cbe 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -46,6 +46,11 @@ impl Tokens { preview_add: Color32::from_rgb(112, 160, 226), preview_sketch: Color32::from_rgb(32, 190, 96), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), + cam_stock: Color32::from_rgb(166, 127, 65), + cam_fixture: Color32::from_rgb(150, 90, 180), + cam_cut: Color32::from_rgb(20, 110, 160), + cam_rapid: Color32::from_rgb(185, 115, 25), + cam_tool: Color32::from_rgb(40, 150, 70), cube_face: Color32::from_rgb(236, 239, 244), cube_border: Color32::from_rgb(85, 94, 110), cube_text: Color32::from_rgb(38, 42, 50), @@ -141,6 +146,11 @@ impl Tokens { t.hover = Color32::from_rgb(54, 61, 74); t.hover_edge_halo = Color32::from_rgb(203, 203, 203); t.origin_point = Color32::from_rgb(214, 215, 218); + t.cam_stock = Color32::from_rgb(190, 154, 92); + t.cam_fixture = Color32::from_rgb(177, 119, 209); + t.cam_cut = Color32::from_rgb(50, 180, 240); + t.cam_rapid = Color32::from_rgb(230, 170, 65); + t.cam_tool = Color32::from_rgb(135, 220, 145); t.viewport_top = Color32::from_rgb(46, 52, 64); t.viewport_bottom = Color32::from_rgb(46, 52, 64); t.grid = Color32::from_rgb(59, 65, 76); @@ -209,6 +219,12 @@ impl Tokens { t.bend_down = Color32::from_rgb(200, 120, 0); t.sketch_accent = Color32::from_rgb(60, 150, 105); } + // Use the measured five-colour semantic set for stock, fixtures and motions. + t.cam_stock = t.sketch_fixed; + t.cam_fixture = t.sketch_projected; + t.cam_cut = t.sketch; + t.cam_rapid = t.sketch_lost; + t.cam_tool = t.sketch_locked; t } @@ -225,6 +241,12 @@ impl Tokens { t.sketch_locked = Color32::from_rgb(20, 165, 70); t.box_crossing = Color32::from_rgb(200, 60, 120); } + // Use the measured five-colour semantic set for stock, fixtures and motions. + t.cam_stock = t.sketch_fixed; + t.cam_fixture = t.sketch_projected; + t.cam_cut = t.sketch; + t.cam_rapid = t.sketch_lost; + t.cam_tool = t.sketch_locked; t } } diff --git a/crates/cam/Cargo.toml b/crates/cam/Cargo.toml new file mode 100644 index 00000000..cf056af5 --- /dev/null +++ b/crates/cam/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "solidcraft-cam" +description = "Portable milling toolpaths and independent NC verification" +version.workspace = true +edition.workspace = true +license.workspace = true +[lints] +workspace = true +[dependencies] +solidcraft-geom.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +sha2.workspace = true +cavalier_contours = "=0.9.0" +parry3d-f64 = { version = "=0.31.1", default-features = false, features = ["required-features", "std", "alloc", "enhanced-determinism"] } +robust = "=1.2.0" diff --git a/crates/cam/src/collision.rs b/crates/cam/src/collision.rs new file mode 100644 index 00000000..fbcb9460 --- /dev/null +++ b/crates/cam/src/collision.rs @@ -0,0 +1,483 @@ +//! Independent fixture collision adapter. No toolpath generation code is used. +//! Surface casts and solid containment are both needed: a surface-only query can +//! miss a tool that starts wholly inside a closed fixture. +use crate::{ + JobInput, POST_ERROR, Result, Tool, ToolKind, invalid, + nc::Segment, + verify::{Finding, Limits, Report, Status}, +}; +use parry3d_f64::{ + math::{Pose, Vector}, + query::{ShapeCastOptions, ShapeCastStatus, cast_shapes, distance}, + shape::{Cone, Cylinder, Shape, Triangle}, +}; +use solidcraft_geom::Vec3; +use std::collections::BTreeMap; + +#[derive(Clone, Copy)] +struct Bounds { + min: Vec3, + max: Vec3, +} +impl Bounds { + fn point(p: Vec3) -> Self { + Self { min: p, max: p } + } + fn include(&mut self, p: Vec3) { + self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z)); + self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z)); + } + fn overlaps(self, b: Self) -> bool { + self.min.x <= b.max.x + && self.max.x >= b.min.x + && self.min.y <= b.max.y + && self.max.y >= b.min.y + && self.min.z <= b.max.z + && self.max.z >= b.min.z + } +} + +struct Component { + body: usize, + triangles: Vec, + bounds: Bounds, + closed: bool, +} +struct Edge { + first: usize, + forward: bool, + count: usize, + consistent: bool, +} +type Vertex = [u64; 3]; +fn vertex(p: Vec3) -> Vertex { + [p.x, p.y, p.z].map(|v| if v == 0.0 { 0 } else { v.to_bits() }) +} +fn root(parents: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parents.get(i) { + if p == i { + break; + } + let next = parents.get(p).copied().unwrap_or(p); + if let Some(slot) = parents.get_mut(i) { + *slot = next; + } + i = next; + } + i +} + +/// Parry's primitives have a Y axis. This orthogonal mapping puts SolidCraft's +/// downward drill tip on +Y without introducing a rounded rotation transform. +fn vector(p: Vec3) -> Vector { + Vector::new(p.x, -p.z, p.y) +} +struct Part { + radius: f64, + low: f64, + high: f64, + cone: bool, + label: &'static str, +} +fn parts(t: &Tool) -> Vec { + let tip = t.tip_length(); + let mut out = Vec::with_capacity(4); + if t.kind == ToolKind::TwistDrill { + out.push(Part { radius: t.diameter / 2.0, low: 0.0, high: tip, cone: true, label: "cutter" }); + } + if t.cutting_length > tip { + out.push(Part { radius: t.diameter / 2.0, low: tip, high: t.cutting_length, cone: false, label: "cutter" }); + } + if t.overall_length > t.cutting_length { + out.push(Part { radius: t.shank_diameter / 2.0, low: t.cutting_length, high: t.overall_length, cone: false, label: "shank" }); + } + out.push(Part { radius: t.holder_diameter / 2.0, low: t.overall_length, high: t.overall_length + t.holder_length, cone: false, label: "holder" }); + out +} +impl Part { + fn center(&self, p: Vec3) -> Vec3 { + p + Vec3::new(0.0, 0.0, (self.low + self.high) / 2.0) + } + fn bounds(&self, s: &Segment, margin: f64) -> Bounds { + let radius = self.radius + margin; + Bounds { + min: Vec3::new(s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius, s.from.z.min(s.to.z) + self.low - margin), + max: Vec3::new(s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius, s.from.z.max(s.to.z) + self.high + margin), + } + } + fn cast(&self, s: &Segment, tri: &[Vec3; 3], margin: f64) -> Status { + let center = vector(self.center(s.from)); + let position = Pose::translation(center.x, center.y, center.z); + let triangle = Triangle::new(vector(tri[0]), vector(tri[1]), vector(tri[2])); + let cylinder = Cylinder::new((self.high - self.low) / 2.0, self.radius); + let cone = Cone::new((self.high - self.low) / 2.0, self.radius); + let shape: &dyn Shape = if self.cone { &cone } else { &cylinder }; + let query = |distance| { + cast_shapes( + &position, + vector(s.to - s.from), + shape, + &Pose::IDENTITY, + Vector::ZERO, + &triangle, + ShapeCastOptions { + max_time_of_impact: 1.0, + target_distance: distance, + stop_at_penetration: true, + compute_impact_geometry_on_penetration: true, + }, + ) + }; + // Third-party numerical errors or panics never certify clearance. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // A zero-velocity shape cast may report no impact even for initial + // penetration. Check the initial pose independently before sweeping. + let initial = match distance(&position, shape, &Pose::IDENTITY, &triangle).map(|d| d.distance) { + Ok(d) if d.is_finite() && d <= 1e-10 => return Status::Fail, + Ok(d) if d.is_finite() && d > margin => Status::Pass, + _ => Status::Inconclusive, + }; + match query(margin) { + Ok(None) => initial, + Ok(Some(_)) => match query(0.0) { + Ok(Some(hit)) + if matches!(hit.status, ShapeCastStatus::Converged | ShapeCastStatus::PenetratingOrWithinTargetDist) + && hit.time_of_impact.is_finite() + && (0.0..=1.0).contains(&hit.time_of_impact) => + { + Status::Fail + } + _ => Status::Inconclusive, + }, + Err(_) => Status::Inconclusive, + } + })) + .unwrap_or(Status::Inconclusive) + } +} + +fn finding(report: &mut Report, status: Status, s: &Segment, part: &Part, index: usize, detail: &str) { + if status == Status::Fail || report.status == Status::Pass { + report.status = status; + } + report.finding(Finding { + severity: if status == Status::Fail { crate::verify::FindingSeverity::Error } else { crate::verify::FindingSeverity::Unresolved }, + record: None, + source: None, + kind: if status == Status::Fail { format!("fixture_{}_collision", part.label) } else { "fixture_clearance_unresolved".into() }, + line: Some(s.line), + at: s.from, + detail: format!("{} of tool {} at motion {}: {detail}", part.label, s.tool, index + 1), + }); +} + +pub(crate) async fn fixtures>( + input: &JobInput, + segments: &[Segment], + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + report: &mut Report, +) -> Result<()> { + let tris = &input.geometry.fixtures; + if tris.is_empty() { + return Ok(()); + } + let mut parents: Vec<_> = (0..tris.len()).collect(); + let mut edges: BTreeMap<(usize, [Vertex; 2]), Edge> = BTreeMap::new(); + let bodies: Vec = if input.geometry.fixture_groups.is_empty() { + vec![0; tris.len()] + } else { + input.geometry.fixture_groups.iter().enumerate().flat_map(|(body, &count)| std::iter::repeat_n(body, count)).collect() + }; + let mut degenerate = Vec::new(); + for (i, tri) in tris.iter().enumerate() { + if i.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + report.work += 3; + if report.work > limits.work { + return Ok(()); + } + if (tri[1] - tri[0]).cross(tri[2] - tri[0]).len() < 1e-12 { + degenerate.push(i); + } + for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] { + let a = vertex(a); + let b = vertex(b); + let forward = a < b; + let key = (bodies.get(i).copied().unwrap_or(0), if forward { [a, b] } else { [b, a] }); + if let Some(e) = edges.get_mut(&key) { + let a = root(&mut parents, e.first); + let b = root(&mut parents, i); + if let Some(slot) = parents.get_mut(b) { + *slot = a; + } + e.count += 1; + e.consistent &= e.forward != forward; + } else { + edges.insert(key, Edge { first: i, forward, count: 1, consistent: true }); + } + } + } + let mut components: BTreeMap = BTreeMap::new(); + for (i, tri) in tris.iter().enumerate() { + let key = root(&mut parents, i); + let c = components.entry(key).or_insert_with(|| Component { + body: bodies.get(i).copied().unwrap_or(0), + triangles: Vec::new(), + bounds: Bounds::point(tri[0]), + closed: true, + }); + c.triangles.push(i); + for p in tri { + c.bounds.include(*p); + } + if i.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + } + for (index, e) in edges.values().enumerate() { + if index.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if (e.count != 2 || !e.consistent) + && let Some(c) = components.get_mut(&root(&mut parents, e.first)) + { + c.closed = false; + } + } + for i in degenerate { + if let Some(c) = components.get_mut(&root(&mut parents, i)) { + c.closed = false; + } + } + let mut body_closed = BTreeMap::new(); + for c in components.values() { + let closed = body_closed.entry(c.body).or_insert(true); + *closed &= c.closed; + } + let margin = POST_ERROR + limits.uncertainty; + for (index, s) in segments.iter().enumerate() { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + let tool = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| crate::Error("unknown simulation tool".into()))?; + for part in parts(tool) { + let swept = part.bounds(s, margin); + let mut windings: BTreeMap = BTreeMap::new(); + for c in components.values() { + report.work += 1; + if report.work.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if report.work > limits.work { + return Ok(()); + } + if !swept.overlaps(c.bounds) { + continue; + } + let center = part.center(s.from); + let containment = Bounds::point(center).overlaps(c.bounds); + let mut solid_angle = 0.0; + let mut status = if c.closed { Status::Pass } else { Status::Inconclusive }; + for &i in &c.triangles { + report.work += 1; + if report.work.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if report.work > limits.work { + return Ok(()); + } + let Some(tri) = tris.get(i) else { + return invalid("invalid fixture triangle index"); + }; + let mut bounds = Bounds::point(tri[0]); + bounds.include(tri[1]); + bounds.include(tri[2]); + if swept.overlaps(bounds) { + let cast = part.cast(s, tri, margin); + if cast == Status::Fail { + status = Status::Fail; + break; + } + if cast == Status::Inconclusive { + status = Status::Inconclusive; + } + } + if c.closed && containment { + let [a, b, d] = tri.map(|v| v - center); + let denominator = a.len() * b.len() * d.len() + a.dot(b) * d.len() + b.dot(d) * a.len() + d.dot(a) * b.len(); + solid_angle += 2.0 * a.dot(b.cross(d)).atan2(denominator); + } + } + if status != Status::Fail && c.closed && containment { + *windings.entry(c.body).or_default() += solid_angle / (4.0 * std::f64::consts::PI); + } + if status != Status::Pass { + finding( + report, + status, + s, + &part, + index, + if status == Status::Fail { + "swept shape contacts the fixture surface" + } else { + "clearance is within the error margin, the query did not converge, or fixture topology is open/degenerate" + }, + ); + } + } + for (body, winding) in &windings { + let status = if !body_closed.get(body).copied().unwrap_or(false) { + Status::Inconclusive + } else if winding.is_finite() && winding.abs() > 1.0 - 1e-6 { + Status::Fail + } else if !winding.is_finite() || winding.abs() > 1e-6 { + Status::Inconclusive + } else { + Status::Pass + }; + if status != Status::Pass { + finding(report, status, s, &part, index, "initial tool volume is inside a fixture or its solid winding is unresolved"); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::{corpus, nc, verify}; + + fn box_mesh(min: Vec3, max: Vec3) -> Vec<[Vec3; 3]> { + let p = [ + min, + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + max, + Vec3::new(min.x, max.y, max.z), + ]; + [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] + .into_iter() + .flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]]) + .collect() + } + fn run(mesh: Vec<[Vec3; 3]>, motion: &str) -> Report { + run_groups(mesh, vec![], motion) + } + fn run_groups(mesh: Vec<[Vec3; 3]>, groups: Vec, motion: &str) -> Report { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixtures = mesh; + input.geometry.fixture_groups = groups; + let src = format!("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\n{motion}\nM30"); + let parsed = nc::parse(&src, &input.tools).unwrap(); + verify::simulate(&input, &parsed, Limits::default()).unwrap() + } + #[test] + fn near_fixture_is_clear_below_wide_holder() { + // Old whole-tool AABB overlaps this fixture. Cutter/shank radius is 3; + // the 10-radius holder starts at Z65 and cannot touch this low block. + let r = run(box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)), "G0 X1"); + assert_eq!(r.status, Status::Pass, "{:?}", r.findings); + } + #[test] + fn thin_fixture_between_endpoints_is_a_collision() { + let r = run(box_mesh(Vec3::new(5.0, -1.0, 16.0), Vec3::new(5.01, 1.0, 17.0)), "G0 X10"); + assert_eq!(r.status, Status::Fail); + assert!(r.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(4))); + } + #[test] + fn holder_and_shank_collisions_are_distinguished() { + for (z, kind) in [(40.0, "shank"), (70.0, "holder")] { + let r = run(box_mesh(Vec3::new(5.0, -1.0, z), Vec3::new(5.1, 1.0, z + 1.0)), "G0 X10"); + assert_eq!(r.status, Status::Fail); + assert!(r.findings.iter().any(|f| f.kind == format!("fixture_{kind}_collision"))); + } + } + #[test] + fn contained_tool_is_not_missed_and_winding_is_irrelevant() { + let cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + for reverse in [false, true] { + let mesh = cube.iter().map(|t| if reverse { [t[2], t[1], t[0]] } else { *t }).collect(); + let r = run(mesh, "G0 X1"); + assert_eq!(r.status, Status::Fail, "{:?}", r.findings); + } + } + #[test] + fn overlapping_components_with_opposite_winding_do_not_cancel() { + let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + a.extend(box_mesh(Vec3::new(-21.0, -21.0, 9.0), Vec3::new(21.0, 21.0, 101.0)).into_iter().map(|t| [t[2], t[1], t[0]])); + assert_eq!(run_groups(a, vec![12, 12], "G0 X1").status, Status::Fail); + } + #[test] + fn cavity_in_one_fixture_body_remains_empty() { + let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + a.extend(box_mesh(Vec3::new(-15.0, -15.0, 11.0), Vec3::new(15.0, 15.0, 99.0)).into_iter().map(|t| [t[2], t[1], t[0]])); + assert_eq!(run_groups(a, vec![24], "G0 X1").status, Status::Pass); + } + #[test] + fn malformed_fixture_group_counts_are_rejected() { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixture_groups = vec![usize::MAX, 1]; + assert!(input.validate().is_err()); + input.geometry.fixture_groups = vec![0]; + assert!(input.validate().is_err()); + } + #[test] + fn open_containing_shell_is_inconclusive() { + let mut cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + cube.pop(); + assert_eq!(run(cube, "G0 X1").status, Status::Inconclusive); + } + #[test] + fn cylinder_corners_and_margin_are_resolved() { + let part = Part { radius: 1.0, low: 0.0, high: 2.0, cone: false, label: "cutter" }; + let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: true, feed: 0.0, tool: 1, line: 4 }; + let triangle = |x, y| [Vec3::new(x, y, 0.5), Vec3::new(x + 0.05, y, 1.5), Vec3::new(x, y + 0.05, 1.5)]; + assert_eq!(part.cast(&s, &triangle(0.8, 0.8), 0.0035), Status::Pass); + assert_eq!(part.cast(&s, &triangle(0.5, 0.5), 0.0035), Status::Fail); + assert_ne!(part.cast(&s, &triangle(1.001, 0.0), 0.0035), Status::Pass); + } + #[test] + fn drill_point_shape_has_clearance_outside_its_cone() { + let part = Part { radius: 1.0, low: 0.0, high: 1.0, cone: true, label: "cutter" }; + let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: false, feed: 100.0, tool: 1, line: 4 }; + let clear = [Vec3::new(0.8, 0.0, 0.1), Vec3::new(0.9, 0.0, 0.1), Vec3::new(0.8, 0.1, 0.2)]; + assert_eq!(part.cast(&s, &clear, 0.0035), Status::Pass); + let contact = clear.map(|p| p + Vec3::new(0.0, 0.0, 0.8)); + assert_eq!(part.cast(&s, &contact, 0.0035), Status::Fail); + } + #[test] + fn fixture_budget_and_cancellation_never_pass() { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixtures = box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)); + let parsed = nc::parse("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X1\nM30", &input.tools).unwrap(); + let r = verify::simulate(&input, &parsed, Limits { work: 1, ..Limits::default() }).unwrap(); + assert_eq!(r.status, Status::Inconclusive); + assert!(verify::simulate_cancellable(&input, &parsed, Limits::default(), || true).is_err()); + } +} diff --git a/crates/cam/src/compare.rs b/crates/cam/src/compare.rs new file mode 100644 index 00000000..978a9397 --- /dev/null +++ b/crates/cam/src/compare.rs @@ -0,0 +1,489 @@ +//! Conservative artifact comparison, separate from toolpath generation. +use crate::spatial::Index; +use crate::{ + verify::{Report, Status}, + *, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Comparison { + pub status: Status, + pub maximum_possible_error: f64, + pub maximum_definite_error: f64, + pub checked_regions: usize, + pub detail: String, +} +fn gaps(a: [f64; 2], b: [f64; 2]) -> (f64, f64) { + ((a[0] - b[1]).max(b[0] - a[1]).max(0.0), (a[0] - b[1]).abs().max((a[1] - b[0]).abs())) +} +fn result(detail: &str) -> Comparison { + Comparison { status: Status::Pass, maximum_possible_error: 0., maximum_definite_error: 0., checked_regions: 0, detail: detail.into() } +} +fn inconclusive(detail: &str) -> Comparison { + Comparison { status: Status::Inconclusive, ..result(detail) } +} +fn tolerance(value: f64) -> Result<()> { + if !value.is_finite() || value <= 0. || value > 1e6 { + return invalid("comparison tolerance must be positive and bounded"); + } + Ok(()) +} +/// Compare both remaining material and removed space, in both directions. +/// Each passing region is a whole-column distance certificate, not a sample. +pub fn stock_reports(a: &Report, b: &Report, tol: f64) -> Result { + stock_reports_budget(a, b, tol, &mut 0, 30_000_000) +} +fn stock_reports_budget(a: &Report, b: &Report, tol: f64, work: &mut usize, budget: usize) -> Result { + tolerance(tol)?; + let (Some(stock), Some(other)) = (a.stock, b.stock) else { + return Ok(inconclusive("missing declared stock bounds; regenerate legacy reports")); + }; + if stock != other { + return invalid("stock bounds differ; verify input units and coordinate frames"); + } + let complete_a = crate::coverage::cells(&a.cells, stock)?; + let complete_b = crate::coverage::cells(&b.cells, stock)?; + if !complete_a || !complete_b || !a.stock_complete || !b.stock_complete || a.status != Status::Pass || b.status != Status::Pass { + return Ok(inconclusive("stock verification or exact cell coverage is incomplete")); + } + let guard = 128. * f64::EPSILON * stock.min.len().max(stock.max.len()).max(1.); + let mut r = result("Bidirectional whole-column distances for remaining material and removed space in declared setup coordinates"); + for (a, b) in [(a, b), (b, a)] { + let index = Index::new(b.cells.iter().enumerate().map(|(id, c)| (id, c.min, c.max)).collect()); + for x in &a.cells { + let mut ids = Vec::new(); + index.query(x.min, x.max, &mut ids); + for id in ids { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock comparison work budget exhausted".into(); + return Ok(r); + } + let y = &b.cells[id]; + let min = [x.min[0].max(y.min[0]), x.min[1].max(y.min[1])]; + let max = [x.max[0].min(y.max[0]), x.max[1].min(y.max[1])]; + if (0..2).any(|k| min[k] >= max[k]) { + continue; + } + r.checked_regions += 1; + let mut possible = gaps([x.low, x.high], [y.low, y.high]).1 + guard; + if possible > tol { + let mut candidates = Vec::new(); + index.query(min.map(|v| v - tol - guard), max.map(|v| v + tol + guard), &mut candidates); + let mut material = (x.high - y.low).max(0.); + // Outside the declared XY stock domain is guaranteed void. + let exterior = (max[0] - stock.min.x).min(stock.max.x - min[0]).min(max[1] - stock.min.y).min(stock.max.y - min[1]); + let mut removed = (y.high - x.low).max(0.).min(exterior); + for id in candidates { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock distance certificate budget exhausted".into(); + return Ok(r); + } + let c = &b.cells[id]; + // The farthest rectangle corner bounds the distance of + // every XY point to this guaranteed material/void prism. + let xy = [0, 1].map(|k| (c.min[k] - min[k]).max(max[k] - c.max[k]).max(0.)); + let lateral = xy[0].hypot(xy[1]); + material = material.min(lateral.hypot((x.high - c.low).max(0.))); + removed = removed.min(lateral.hypot((c.high - x.low).max(0.))); + } + possible = material.max(removed) + guard; + if possible > tol { + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + // A positive failure requires a definitely present + // material/void point farther than tolerance from ALL + // possibly corresponding prisms, not just a Z gap. + let radius = 2. * tol + guard; + let mut candidates = Vec::new(); + index.query(center.map(|v| v - radius), center.map(|v| v + radius), &mut candidates); + let mut material_distance = radius; + let mut removed_distance = radius + .min(center[0] - stock.min.x) + .min(stock.max.x - center[0]) + .min(center[1] - stock.min.y) + .min(stock.max.y - center[1]); + for id in candidates { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock failure certificate budget exhausted".into(); + return Ok(r); + } + let c = &b.cells[id]; + let dx = (c.min[0] - center[0]).max(center[0] - c.max[0]).max(0.); + let dy = (c.min[1] - center[1]).max(center[1] - c.max[1]).max(0.); + material_distance = material_distance.min(dx.hypot(dy).hypot((x.low - c.high).max(0.))); + removed_distance = removed_distance.min(dx.hypot(dy).hypot((c.low - x.high).max(0.))); + } + let definite = (material_distance.max(removed_distance) - guard).max(0.); + r.maximum_definite_error = r.maximum_definite_error.max(definite); + if definite > tol { + r.status = Status::Fail; + r.detail = + format!("Certified local stock difference near X{} Y{}; lower distance bound {} mm", center[0], center[1], definite); + r.maximum_possible_error = r.maximum_possible_error.max(possible); + return Ok(r); + } + r.status = Status::Inconclusive; + } + } + r.maximum_possible_error = r.maximum_possible_error.max(possible); + } + } + } + Ok(r) +} + +pub fn stock_mesh(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64) -> Result { + stock_mesh_budget(report, mesh, bottom, tol, 250_000, 30_000_000) +} +fn stock_mesh_budget(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64, cell_budget: usize, budget: usize) -> Result { + tolerance(tol)?; + if mesh.is_empty() || mesh.len() > 500_000 || !bottom.is_finite() { + return invalid("reference stock mesh empty or too large, or invalid bottom"); + } + let Some(stock) = report.stock else { + return Ok(inconclusive("missing declared stock bounds; regenerate legacy report")); + }; + if bottom != stock.min.z { + return invalid("reference stock bottom differs from declared stock"); + } + let field = crate::coverage::MeshField::new(mesh, stock)?; + let mut cells = Vec::new(); + if !crate::coverage::cells(&report.cells, stock)? || !report.stock_complete || report.status != Status::Pass { + return Ok(inconclusive("stock verification or exact cell coverage is incomplete")); + } + // Reuse only the spatial partition, never the report's heights as mesh + // evidence. Every reference interval is calculated from its own triangles. + let mut pending: Vec<_> = report.cells.iter().map(|c| (c.min, c.max, [c.low, c.high])).collect(); + let mut work = 0; + while let Some((min, max, source)) = pending.pop() { + if cells.len() + pending.len() >= cell_budget { + return Ok(inconclusive("reference stock refinement cell budget exhausted")); + } + let Some([low, high]) = field.bounds(min, max, &mut work, budget) else { + return Ok(inconclusive("reference stock refinement work budget exhausted")); + }; + let size = (max[0] - min[0]).hypot(max[1] - min[1]); + if gaps(source, [low, high]).1 > tol && high - low > VERIFY_ERROR.min(tol / 4.) && size > VERIFY_ERROR.min(tol / 4.) { + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + if (0..2).any(|k| center[k] <= min[k] || center[k] >= max[k]) { + return Ok(inconclusive("reference refinement reached coordinate precision limit")); + } + for (a, b) in [(min, center), ([center[0], min[1]], [max[0], center[1]]), (center, max), ([min[0], center[1]], [center[0], max[1]])] { + pending.push((a, b, source)); + } + } else { + cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high }); + } + } + let reference = Report { cells, status: Status::Pass, stock_complete: true, ..report.clone() }; + stock_reports_budget(report, &reference, tol, &mut work, budget) +} +/// Strict STL decoding: malformed triangles are rejected, never silently dropped. +pub fn read_stl(bytes: &[u8]) -> Result> { + if bytes.len() > 128 * 1024 * 1024 { + return invalid("STL exceeds 128 MiB"); + } + let mut triangles = Vec::new(); + let count = bytes.get(80..84).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes).map(|n| n as usize); + if let Some(count) = count.filter(|n| 84usize.checked_add(n.saturating_mul(50)) == Some(bytes.len())) { + if count > 500_000 { + return invalid("STL triangle limit"); + } + for chunk in bytes.get(84..).unwrap_or_default().as_chunks::<50>().0 { + let mut pts = [Vec3::ZERO; 3]; + for (k, p) in pts.iter_mut().enumerate() { + let mut xyz = [0.0; 3]; + for (j, v) in xyz.iter_mut().enumerate() { + let start = 12 + k * 12 + j * 4; + let b = + chunk.get(start..start + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).ok_or_else(|| Error("truncated STL vertex".into()))?; + *v = f32::from_le_bytes(b) as f64; + } + *p = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(*p)?; + } + triangles.push(pts); + } + } else { + let text = std::str::from_utf8(bytes).map_err(|_| Error("invalid STL encoding/length".into()))?; + let mut lines = text.lines().map(|line| line.split_whitespace().collect::>()).filter(|line| !line.is_empty()); + if lines.next().and_then(|line| line.first().copied()) != Some("solid") { + return invalid("invalid ASCII STL header"); + } + loop { + let header = lines.next().ok_or_else(|| Error("missing STL terminator".into()))?; + if header.first() == Some(&"endsolid") { + if lines.next().is_some() { + return invalid("data after STL terminator"); + } + break; + } + if header.len() != 5 || header[..2] != ["facet", "normal"] || header[2..].iter().any(|v| !v.parse::().is_ok_and(f64::is_finite)) { + return invalid("invalid STL facet header"); + } + if lines.next().as_deref() != Some(&["outer", "loop"]) { + return invalid("missing STL outer loop"); + } + let mut points = [Vec3::ZERO; 3]; + for point in &mut points { + let words = lines.next().ok_or_else(|| Error("incomplete STL triangle".into()))?; + if words.len() != 4 || words[0] != "vertex" { + return invalid("invalid STL vertex"); + } + let mut xyz = [0.0; 3]; + for (i, word) in words[1..].iter().enumerate() { + xyz[i] = word.parse::().map_err(|_| Error("invalid STL number".into()))?; + } + *point = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(*point)?; + } + if lines.next().as_deref() != Some(&["endloop"]) || lines.next().as_deref() != Some(&["endfacet"]) { + return invalid("incomplete STL facet"); + } + triangles.push(points); + if triangles.len() > 500_000 { + return invalid("STL triangle limit"); + } + } + } + if triangles.is_empty() { + return invalid("empty STL"); + } + Ok(triangles) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn cell(x0: f64, x1: f64, low: f64, high: f64) -> verify::Cell { + verify::Cell { min: [x0, 0.], max: [x1, 2.], low, high, target_low: low, target_high: high } + } + fn report(cells: Vec) -> Report { + Report { + version: VERSION.into(), + fingerprint: "analytic".into(), + status: Status::Pass, + finished_dimensions: Status::Pass, + post_version: "analytic".into(), + tolerance: TOLERANCE, + uncertainty: VERIFY_ERROR, + maximum_height_interval: 0., + stock_complete: true, + stock: Some(Stock { min: Vec3::ZERO, max: Vec3::new(2., 2., 3.) }), + cells, + findings: vec![], + cutting_distance: 0., + rapid_distance: 0., + cutting_minutes: 0., + remaining_volume_low: 0., + remaining_volume_high: 0., + work: 0, + } + } + fn mesh(report: &Report) -> Vec<[Vec3; 3]> { + read_stl(verify::stock_stl(report, 0.).unwrap().as_bytes()).unwrap() + } + #[test] + fn duplicate_open_and_misoriented_reference_triangles_cannot_pass() { + let r = report(vec![cell(0., 2., 1., 1.)]); + let tri = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(2., 2., 1.)]; + assert!(stock_mesh(&r, &[tri, tri], 0., TOLERANCE).is_err()); + let mut m = mesh(&r); + m.pop(); + assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err()); + let mut m = mesh(&r); + for t in &mut m { + t.swap(0, 1); + } + assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err()); + } + #[test] + fn cell_coverage_has_no_area_epsilon_and_rejects_duplicates() { + let full = report(vec![cell(0., 2., 1., 1.)]); + let duplicate = report(vec![cell(0., 1., 1., 1.), cell(0., 1., 1., 1.)]); + assert!(stock_reports(&full, &duplicate, TOLERANCE).is_err()); + let gap = report(vec![cell(0., 1., 1., 1.), cell(1. + 1e-10, 2., 1., 1.)]); + assert_eq!(stock_reports(&full, &gap, TOLERANCE).unwrap().status, Status::Inconclusive); + let mut legacy = full.clone(); + legacy.stock = None; + assert_eq!(stock_reports(&full, &legacy, TOLERANCE).unwrap().status, Status::Inconclusive); + let mut shifted = full.clone(); + shifted.stock.as_mut().unwrap().min.x = -1.; + assert!(stock_reports(&full, &shifted, TOLERANCE).is_err()); + let mut bad = full.clone(); + bad.cells[0].high = f64::NAN; + assert!(stock_reports(&full, &bad, TOLERANCE).is_err()); + let mut bad = full.clone(); + bad.cells[0].min[0] = 1e-200; + assert!(stock_reports(&full, &bad, TOLERANCE).is_err()); + } + #[test] + fn whole_column_certificates_resolve_wall_uncertainty_and_detect_wall_shift() { + let exact = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let band = report(vec![cell(0., 0.999, 1., 1.), cell(0.999, 1.001, 1., 2.), cell(1.001, 2., 2., 2.)]); + let r = stock_reports(&exact, &band, TOLERANCE).unwrap(); + assert_eq!(r.status, Status::Pass, "{r:?}"); + assert!(r.maximum_possible_error < 0.003); + let shifted = report(vec![cell(0., 1.1, 1., 1.), cell(1.1, 2., 2., 2.)]); + assert_eq!(stock_reports(&exact, &shifted, TOLERANCE).unwrap().status, Status::Fail); + let wide = report(vec![cell(0., 2., 1., 2.)]); + assert_eq!(stock_reports(&wide, &wide, TOLERANCE).unwrap().status, Status::Inconclusive); + assert_eq!(stock_reports_budget(&exact, &exact, TOLERANCE, &mut 0, 1).unwrap().status, Status::Inconclusive); + } + #[test] + fn removed_space_detects_a_narrow_deep_missing_hole() { + let full = report(vec![cell(0., 2., 2., 2.)]); + // A solid-only Hausdorff test could hide this narrow cut because all + // missing material is within tolerance of a neighboring solid column. + let hole = report(vec![cell(0., 0.999, 2., 2.), cell(0.999, 1.001, 0., 0.), cell(1.001, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &hole, TOLERANCE).unwrap().status, Status::Fail); + let rim = report(vec![cell(0., 0.001, 0., 0.), cell(0.001, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Pass); + let rim = report(vec![cell(0., 0.1, 0., 0.), cell(0.1, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Fail); + } + #[test] + fn closed_stock_mesh_compares_at_vertical_walls_and_holes() { + let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let m = mesh(&step); + let comparison = stock_mesh(&step, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + let hole = report(vec![cell(0., 0.9, 2., 2.), cell(0.9, 1.1, 0., 0.), cell(1.1, 2., 2., 2.)]); + let comparison = stock_mesh(&hole, &mesh(&hole), 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 1, 100).unwrap().status, Status::Inconclusive); + } + #[test] + fn shared_mesh_edges_do_not_refine_flat_cells_or_hide_thin_overlaps() { + let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let m = mesh(&step); + assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 3, 100_000).unwrap().status, Status::Pass); + let field = crate::coverage::MeshField::new(&m, step.stock.unwrap()).unwrap(); + let flat = field.bounds([0., 0.], [1., 2.], &mut 0, 1000).unwrap(); + assert!((flat[0] - 1.).abs() < 1e-10 && (flat[1] - 1.).abs() < 1e-10); + // Positive-area overlap has no area epsilon: even this tiny strip must + // retain both heights, rather than treating it as a shared edge. + let sliver = field.bounds([0., 0.], [1. + 1e-10, 2.], &mut 0, 1000).unwrap(); + assert!(sliver[0] <= 1. && sliver[1] >= 2.); + } + #[test] + fn overlapping_closed_components_cannot_counterfeit_full_coverage() { + let expected = report(vec![cell(0., 2., 1., 1.)]); + let mut m = mesh(&report(vec![cell(0., 1., 1., 1.)])); + m.extend(mesh(&report(vec![cell(0.1, 1.1, 1., 1.)]))); + let comparison = stock_mesh(&expected, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Fail, "{comparison:?}"); + let mut extra = mesh(&expected); + for t in &mut extra { + for p in t { + p.x += 1.; + } + } + assert!(stock_mesh(&expected, &extra, 0., TOLERANCE).is_err()); + } + #[test] + fn generated_drill_stock_round_trip_is_spatially_certified() { + let input = corpus::fixture("drill").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let r = verify::simulate(&input, &parsed, Default::default()).unwrap(); + let m = mesh(&r); + let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + } + #[test] + fn analytic_round_hole_matches_independently_faceted_closed_stock() { + // Original analytic solid: 2x2 plate, top Z2, radius .25 bore, a + // cone from tip Z1 to Z1.5, and a vertical wall to the plate top. + // Intervals below use only radial extrema; no toolpath or STL exporter. + let height = |d: f64| if d < 0.25 { 1. + 2. * d } else { 2. }; + let mut cells = Vec::new(); + let mut pending = vec![([0., 0.], [2., 2.])]; + while let Some((min, max)) = pending.pop() { + let near: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).max(1. - max[k]).max(0.)); + let far: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).abs().max((max[k] - 1.).abs())); + let low = height(near[0].hypot(near[1])); + let high = height(far[0].hypot(far[1])); + if high - low > 0.002 && (max[0] - min[0]).hypot(max[1] - min[1]) > VERIFY_ERROR { + let c = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + pending.extend([(min, c), ([c[0], min[1]], [max[0], c[1]]), (c, max), ([min[0], c[1]], [c[0], max[1]])]); + } else { + cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high }); + } + } + let r = report(cells); + let mut m = Vec::new(); + let ring = |i: usize| { + let a = (i % 64) as f64 * std::f64::consts::TAU / 64.; + let (y, x) = a.sin_cos(); + let outer = + if x.abs() >= y.abs() { Vec3::new(1. + x.signum(), 1. + y / x.abs(), 2.) } else { Vec3::new(1. + x / y.abs(), 1. + y.signum(), 2.) }; + [Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 2.), Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 1.5), outer, Vec3::new(outer.x, outer.y, 0.)] + }; + for i in 0..64 { + let [a, lo, outer, base] = ring(i); + let [b, lo_b, outer_b, base_b] = ring(i + 1); + m.extend([ + [a, outer, outer_b], + [a, outer_b, b], + [a, b, lo_b], + [a, lo_b, lo], + [Vec3::new(1., 1., 1.), lo, lo_b], + [outer, base, base_b], + [outer, base_b, outer_b], + [Vec3::new(1., 1., 0.), base_b, base], + ]); + } + let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + // Different facet decomposition must preserve the result. + let split: Vec<_> = m + .iter() + .flat_map(|t| { + if (t[1] - t[0]).cross(t[2] - t[0]).z == 0. { + return vec![*t]; + } + let c = (t[0] + t[1] + t[2]) / 3.; + vec![[t[0], t[1], c], [t[1], t[2], c], [t[2], t[0], c]] + }) + .collect(); + assert_eq!(stock_mesh(&r, &split, 0., TOLERANCE).unwrap().status, Status::Pass); + let missed = report(vec![cell(0., 2., 2., 2.)]); + assert_eq!(stock_mesh(&missed, &m, 0., TOLERANCE).unwrap().status, Status::Fail); + } + #[test] + fn invalid_stl_is_not_partially_accepted() { + assert!(read_stl(b"solid x\nvertex NaN 0 0\nvertex 1 0 0\nvertex 0 1 0\nendsolid").is_err()); + assert!(read_stl(&[0; 84]).is_err()); + } + #[test] + fn interval_difference_catches_local_gouge() { + assert!((gaps([4.0, 4.001], [5.0, 5.001]).0 - 0.999).abs() < 1e-10); + assert!(gaps([4.99, 5.01], [5.0, 5.0]).1 > 0.009); + } + #[test] + fn incomplete_coverage_is_symmetric_and_stl_is_bounded() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let full = verify::simulate(&input, &parsed, Default::default()).unwrap(); + let mut partial = full.clone(); + partial.cells.truncate(1); + assert_eq!(stock_reports(&partial, &full, TOLERANCE).unwrap().status, Status::Inconclusive); + assert_eq!(stock_reports(&full, &partial, TOLERANCE).unwrap().status, Status::Inconclusive); + let stl = verify::stock_stl(&full, 0.0).unwrap(); + let mesh = read_stl(stl.as_bytes()).unwrap(); + assert_eq!(mesh.len(), 12); // exact coplanar merge, conforming boundary + let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + assert!((volume - 2000.0).abs() < 1e-9); + assert!(read_stl(stl.replace("endfacet", "unknown").as_bytes()).is_err()); + } +} diff --git a/crates/cam/src/corpus.rs b/crates/cam/src/corpus.rs new file mode 100644 index 00000000..bee8007d --- /dev/null +++ b/crates/cam/src/corpus.rs @@ -0,0 +1,388 @@ +//! Original analytic fixtures; never generated by a proprietary CAD application. +use crate::*; +use solidcraft_geom::Vec3; +mod channel; +mod extended; +mod exterior; +mod through; + +pub const CASES: &[&str] = + &["face", "contour", "pocket", "drill", "concave-islands", "multi-operation", "clamped-through-holes", "external-contour", "narrow-channel"]; + +/// Independent point expectations, indexed by the completed operation count. +pub fn probes(name: &str, completed: usize) -> Result> { + match name { + "face" | "pocket" | "drill" => Ok(vec![([10., 10.], 5.)]), + "contour" => Ok(vec![([10., 10.], 6.)]), + "narrow-channel" if completed == 1 => Ok(vec![([10., 10.], 5.), ([4., 10.], 5.), ([16., 10.], 5.), ([10., 11.2], 6.), ([3.8, 10.], 6.)]), + "concave-islands" => Ok(vec![([6., 6.], 6.), ([6., 13.], 6.), ([14., 14.], 6.), ([15., 6.], 4.), ([6., 9.], 4.)]), + "clamped-through-holes" if (1..=2).contains(&completed) => { + Ok(vec![([6., 10.], 0.), ([14., 10.], if completed == 2 { 0. } else { 6. }), ([10., 10.], 6.), ([6.6, 10.], 6.)]) + } + "external-contour" if (1..=2).contains(&completed) => { + Ok(vec![([10., 10.], 6.), ([5.1, 10.], 6.), ([4.5, 10.], if completed == 1 { 4.2 } else { 4. }), ([1., 1.], 6.)]) + } + "multi-operation" if (1..=4).contains(&completed) => { + let floor = [5.5, 4.2, 4., 4.].get(completed - 1).copied().ok_or_else(|| Error("unknown stage".into()))?; + Ok(vec![([1., 1.], 5.5), ([6., 6.], 5.5), ([14., 14.], 5.5), ([6., 9.], floor), ([15., 6.], if completed == 4 { 3. } else { floor })]) + } + _ => invalid("unknown analytic stage"), + } +} + +pub fn fixture(strategy: &str) -> Result { + if strategy == "narrow-channel" { + return channel::fixture(); + } + if strategy == "external-contour" { + return exterior::fixture(); + } + if strategy == "clamped-through-holes" { + return through::fixture(); + } + if matches!(strategy, "concave-islands" | "multi-operation") { + return extended::fixture(strategy); + } + let tool = Tool { + id: 1, + name: "6 mm center-cutting end mill".into(), + number: 1, + kind: ToolKind::FlatEndMill, + diameter: 6.0, + cutting_length: 20.0, + shank_diameter: 6.0, + overall_length: 50.0, + holder_diameter: 20.0, + holder_length: 30.0, + tip_angle: 118_f64.to_radians(), + }; + let setup = Setup { + id: 1, + name: "Analytic plate".into(), + stock: Stock { min: Vec3::ZERO, max: Vec3::new(20.0, 20.0, 6.0) }, + frame: Frame::default(), + models: vec![BodyRef { body: "Plate".into(), occurrence: vec![] }], + fixtures: vec![], + clearance: 15.0, + retract: 10.0, + work_offset: 54, + }; + let boundary = Boundary { + outer: (0..128) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 128.; + [10. + 8. * a.cos(), 10. + 8. * a.sin()] + }) + .collect(), + islands: vec![], + }; + let geometry = match strategy { + "face" => Strategy::Face, + "contour" => Strategy::Contour { boundary, inside: true }, + "pocket" => Strategy::Pocket { boundary }, + "drill" => Strategy::Drill { holes: vec![[10.0, 10.0]], peck: Some(1.0), full_diameter: false }, + _ => return invalid("unknown analytic fixture"), + }; + let mut tool = tool; + if strategy == "drill" { + tool.kind = ToolKind::TwistDrill; + tool.diameter = 1.0; + tool.shank_diameter = 1.0; + tool.name = "1 mm twist drill".into(); + } + let operation = Operation { + id: 1, + name: strategy.into(), + setup: 1, + tool: 1, + geometry, + source: None, + hole_sources: vec![], + top: 6.0, + bottom: 5.0, + stepdown: 1.0, + stepover: if strategy == "drill" { 0.5 } else { 2.0 }, + feed: 300.0, + plunge_feed: 100.0, + spindle: 6000.0, + allowance: 0.0, + climb: true, + tolerance: TOLERANCE, + coolant: false, + ramp_angle: None, + lead_distance: 0.0, + }; + let a = Vec3::new(0.0, 0.0, 5.0); + let b = Vec3::new(20.0, 0.0, 5.0); + let c = Vec3::new(20.0, 20.0, 5.0); + let d = Vec3::new(0.0, 20.0, 5.0); + let mut input = JobInput { + setup, + tools: vec![tool], + operations: vec![operation], + geometry: Geometry { target: vec![[a, b, c], [a, c, d]], fixtures: vec![], fixture_groups: vec![] }, + fingerprint: String::new(), + }; + if strategy == "drill" { + input.geometry.target = drilled_plate_target(); + } + if matches!(strategy, "contour" | "pocket") { + input.geometry.target = round_pocket_target(); + } + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +// Original circular pocket with the same faceted boundary as its input recipe. +// Chord error at radius 8 with 128 segments is 0.002410 mm; the recipe itself is +// this polygon, not an unrecorded exact circle. +fn round_pocket_target() -> Vec<[Vec3; 3]> { + let mut triangles = Vec::new(); + let ring = |i: usize| { + let a = (i % 128) as f64 * std::f64::consts::TAU / 128.; + let (y, x) = a.sin_cos(); + let outer = if x.abs() >= y.abs() { + [10. + 10. * x.signum(), 10. + 10. * y / x.abs(), 6.] + } else { + [10. + 10. * x / y.abs(), 10. + 10. * y.signum(), 6.] + }; + ([10. + 8. * x, 10. + 8. * y, 6.], outer) + }; + for i in 0..128 { + let (a, c) = ring(i); + let (b, d) = ring(i + 1); + triangles.extend([[a.into(), c.into(), d.into()], [a.into(), d.into(), b.into()]]); + let e = [a[0], a[1], 5.]; + let f = [b[0], b[1], 5.]; + triangles.extend([[[10., 10., 5.].into(), e.into(), f.into()], [a.into(), b.into(), f.into()], [a.into(), f.into(), e.into()]]); + } + triangles +} + +// Original faceted plate with a blind drilled hole, vertical wall and cone. +fn drilled_plate_target() -> Vec<[Vec3; 3]> { + let mut triangles = Vec::new(); + let n = 48; + let slope = 1.0 / (59_f64.to_radians().tan()); + for k in 0..n { + let point = |index: usize, r: f64, z: f64| { + let a = index as f64 * std::f64::consts::TAU / n as f64; + [10. + r * a.cos(), 10. + r * a.sin(), z] + }; + let outer = |index: usize| { + let a = index as f64 * std::f64::consts::TAU / n as f64; + point(index, 10. / a.cos().abs().max(a.sin().abs()), 6.) + }; + let a = point(k, 0.5, 6.); + let b = point(k + 1, 0.5, 6.); + let c = outer(k); + let d = outer(k + 1); + triangles.push([a.into(), c.into(), d.into()]); + triangles.push([a.into(), d.into(), b.into()]); + let e = point(k, 0.5, 5. + 0.5 * slope); + let f = point(k + 1, 0.5, 5. + 0.5 * slope); + triangles.push([[10., 10., 5.].into(), e.into(), f.into()]); + triangles.push([a.into(), b.into(), f.into()]); + triangles.push([a.into(), f.into(), e.into()]); + } + triangles +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn all_strategies_round_trip() { + for name in ["face", "contour", "pocket", "drill"] { + let input = fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + assert!(!nc::segments(&parsed, &input.setup).unwrap().is_empty()); + } + } + #[test] + fn missing_modes_and_unknown_words_fail() { + let input = fixture("face").unwrap(); + for code in [ + "G0 X0 Y0 Z10\nM30", + "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG43 H1\nM30", + "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG0 XNaN\nM30", + "G21 G90 G91\nM30", + ] { + assert!(nc::parse(code, &input.tools).is_err(), "{code}"); + } + } + #[test] + fn cancelled_job_cannot_publish() { + let mut g = generate::Generator::new(fixture("face").unwrap()).unwrap(); + g.cancel(); + assert!(g.step().is_err()); + assert!(g.finish().is_err()); + } + #[test] + fn malformed_geometry_rejected() { + let mut i = fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry { + boundary.outer = vec![[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]]; + } + assert!(generate::generate(i).is_err()); + } + #[test] + fn zero_budget_cannot_pass() { + let i = fixture("face").unwrap(); + let p = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap(); + let r = verify::simulate(&i, &nc, verify::Limits { work: 1, ..Default::default() }).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + } + #[test] + fn equivalent_units() { + let i = fixture("face").unwrap(); + let a = nc::parse("G21 G90 G17 G94 G54\nG0 X25.4 Y0 Z25.4\nM30", &i.tools).unwrap(); + let b = nc::parse("G20 G90 G17 G94 G54\nG0 X1 Y0 Z1\nM30", &i.tools).unwrap(); + assert_eq!(a.records[0].motion, b.records[0].motion); + } + #[test] + fn direction_changes_arc_sweep() { + let i = fixture("face").unwrap(); + let src = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X0 Y1 I-1 J0 F100\nM5\nM30"; + let a = nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).unwrap(); + let b = nc::segments(&nc::parse(&src.replace("G3 ", "G2 "), &i.tools).unwrap(), &i.setup).unwrap(); + let len = |s: Vec| s.iter().map(|x| x.from.dist(x.to)).sum::(); + assert!((len(b) / len(a) - 3.0).abs() < 0.01); + } + #[test] + fn speed_selection_does_not_start_a_stopped_spindle() { + let i = fixture("face").unwrap(); + let src = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000\nG1 X2 F100\nM30"; + assert!(nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).is_err()); + let running = src.replace("S6000", "S6000 M3"); + let parsed = nc::parse(&running, &i.tools).unwrap(); + assert_eq!(parsed.records.last().unwrap().motion, Motion::Spindle { rpm: 0.0 }); + } + #[test] + fn unsafe_rapid_and_wide_shank_do_not_pass() { + let mut i = fixture("face").unwrap(); + let rapid = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X10 Y10 Z2\nM30"; + let p = nc::parse(rapid, &i.tools).unwrap(); + assert_ne!( + verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap().status, + verify::Status::Pass + ); + i.tools[0].diameter = 1.0; + i.tools[0].cutting_length = 1.0; + i.operations[0].stepover = 0.5; + let shank = "G21 G90 G17 G94 G54\nG0 X-1 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z3 F100\nM5\nM30"; + let p = nc::parse(shank, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + assert!(r.findings.iter().any(|f| f.kind.contains("shank") || f.kind.contains("holder") || f.kind == "budget_exhausted")); + } + #[test] + fn gouge_and_fixture_overlap_are_blocked() { + let mut i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z4.000000"); + let p = nc::parse(&code, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, Default::default()).unwrap(); + assert_eq!(r.status, verify::Status::Fail); + i.geometry.fixtures.push([Vec3::new(9.0, 9.0, 6.0), Vec3::new(11.0, 9.0, 6.0), Vec3::new(10.0, 11.0, 10.0)]); + let p = nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, Default::default()).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + } + #[test] + fn simulation_observes_cancellation() { + let i = fixture("face").unwrap(); + let p = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap(); + assert!(verify::simulate_cancellable(&i, &nc, Default::default(), || true).is_err()); + } + #[test] + fn islands_winding_ramps_and_tool_fit() { + let mut i = fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry { + boundary.outer.reverse(); + boundary.islands.push(vec![[9.0, 9.0], [11.0, 9.0], [11.0, 11.0], [9.0, 11.0]]); + } + i.tools[0].diameter = 2.0; + i.operations[0].stepover = 0.5; + i.operations[0].ramp_angle = Some(5_f64.to_radians()); + let path = generate::generate(i.clone()).unwrap(); + nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + i.tools[0].diameter = 30.0; + assert!(generate::generate(i).is_err()); + } + #[test] + fn hand_calculated_stock_and_drill_cone() { + let i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + let report = verify::simulate(&i, &nc, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Pass); + assert!((report.remaining_volume_low - 2000.0).abs() < 1e-6); + assert!((report.remaining_volume_high - 2000.0).abs() < 1e-6); + let i = fixture("drill").unwrap(); + // Authored NC, independent of the generator. A 118-degree cone rises + // r / tan(59 degrees) from its tip at every radial position r. + let source = "G21 G90 G17 G94 G54\nG0 X10 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z5 F100\nG0 Z15\nM5\nM30"; + let nc = nc::parse(source, &i.tools).unwrap(); + let actual = verify::probe_height(&i, &nc, [10.25, 10.0]).unwrap(); + assert!((actual - (5.0 + 0.25 / 59_f64.to_radians().tan())).abs() < 1e-9); + assert_eq!(verify::probe_height(&i, &nc, [11.0, 10.0]).unwrap(), 6.0); + } + + #[test] + fn climb_pocket_follows_counterclockwise_outer_wall() { + let i = fixture("pocket").unwrap(); + let path = generate::generate(i).unwrap(); + let points: Vec<_> = path + .blocks + .iter() + .filter_map(|b| match b.motion { + Motion::Linear { to, feed: 300.0 } => Some(to), + _ => None, + }) + .collect(); + // With M3, the pocket wall lies to the right of a counterclockwise cut. + let last = &points[points.len() - 128..]; + let area: f64 = last.iter().zip(last.iter().cycle().skip(1)).take(128).map(|(a, b)| a.x * b.y - a.y * b.x).sum(); + assert!(area > 0.0); + } + #[test] + fn cooperative_verification_yields_and_cancels() { + use std::future::Future; + use std::task::{Context, Poll, Waker}; + let input = fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let nc = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let cancel = std::cell::Cell::new(false); + let yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut future = std::pin::pin!(verify::simulate_cooperative(&input, &nc, Default::default(), || cancel.get(), yield_once)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + } + #[test] + fn excess_facing_stock_is_a_dimensional_failure() { + let i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z5.100000"); + let nc = nc::parse(&code, &i.tools).unwrap(); + let report = verify::simulate(&i, &nc, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Pass); // no gouge or collision + assert_eq!(report.finished_dimensions, verify::Status::Fail); + } +} diff --git a/crates/cam/src/corpus/channel.rs b/crates/cam/src/corpus/channel.rs new file mode 100644 index 00000000..7f0f1b52 --- /dev/null +++ b/crates/cam/src/corpus/channel.rs @@ -0,0 +1,98 @@ +//! Original narrow-channel recipes with independently triangulated protected stock. +use crate::*; + +fn capsule() -> Boundary { + let mut outer = Vec::new(); + for (x, start) in [(5., 90_f64), (15., 270_f64)] { + for i in 0..=64 { + let a = (start + 180. * i as f64 / 64.).to_radians(); + outer.push([x + 1.1 * a.cos(), 10. + 1.1 * a.sin()]); + } + } + Boundary { outer, islands: vec![] } +} +fn with_boundary(boundary: Boundary) -> Result { + let mut input = super::fixture("pocket")?; + input.setup.name = "2.2 mm capsule channel".into(); + input.tools[0].name = "2 mm center-cutting end mill".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.operations[0].name = "Finish narrow channel".into(); + input.operations[0].stepover = 0.75; + input.operations[0].geometry = Strategy::Pocket { boundary: boundary.clone() }; + input.geometry.target.clear(); + super::extended::flat(&mut input.geometry.target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), 6.); + super::extended::flat(&mut input.geometry.target, &boundary.outer, &[], 5.); + super::extended::wall(&mut input.geometry.target, &boundary.outer, 6., 5.); + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} +pub(super) fn fixture() -> Result { + with_boundary(capsule()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::verify::Status; + + fn program(input: &JobInput) -> nc::Parsed { + let path = generate::generate(input.clone()).unwrap(); + nc::round_trip(&path, &input.setup, &input.tools).unwrap() + } + + #[test] + fn narrow_channel_finishes_protected_walls_and_floor() { + let input = fixture().unwrap(); + // A rectangle plus two polygonal semicircles, each with 64 segments. + let area = 22. + 64. * 1.1_f64.powi(2) * (std::f64::consts::PI / 64.).sin(); + let volume: f64 = + input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - (2400. - area)).abs() < 1e-8); + let parsed = program(&input); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!((report.status, report.finished_dimensions), (Status::Pass, Status::Pass), "{:?}", report.findings); + for (xy, expected) in super::super::probes("narrow-channel", 1).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + } + + #[test] + fn oversized_tool_and_inaccessible_neck_cannot_succeed() { + let mut input = fixture().unwrap(); + input.tools[0].diameter = 2.4; + input.tools[0].shank_diameter = 2.4; + assert!(generate::generate(input).unwrap_err().0.contains("tool cannot fit pocket")); + + // R2 lobes linked by a 1.6 mm wide, >6 mm long neck. A 2 mm tool + // can enter both lobes but cannot reach the center of the neck. + let angle = 0.4_f64.asin(); + let mut outer = Vec::new(); + for (x, start) in [(5., angle), (15., std::f64::consts::PI + angle)] { + for i in 0..=128 { + let a = start + (std::f64::consts::TAU - 2. * angle) * i as f64 / 128.; + outer.push([x + 2. * a.cos(), 10. + 2. * a.sin()]); + } + } + let input = with_boundary(Boundary { outer, islands: vec![] }).unwrap(); + let parsed = program(&input); + assert_eq!(verify::probe_height(&input, &parsed, [10., 10.]).unwrap(), 6.); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.kind == "finished_region_error")); + } + + #[test] + fn unsafe_lead_is_rejected_against_original_protected_geometry() { + let mut input = fixture().unwrap(); + input.operations[0].lead_distance = 5.; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input).unwrap(); + let parsed = program(&input); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.severity == verify::FindingSeverity::Error && f.source.is_some())); + } +} diff --git a/crates/cam/src/corpus/extended.rs b/crates/cam/src/corpus/extended.rs new file mode 100644 index 00000000..40854062 --- /dev/null +++ b/crates/cam/src/corpus/extended.rs @@ -0,0 +1,220 @@ +//! Original dimensioned recipes. Analytic surfaces are triangulated independently +//! of the CAM offset/generation algorithms; no proprietary artifacts are used. +use crate::*; +use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3}; + +fn circle(center: [f64; 2], radius: f64) -> Vec<[f64; 2]> { + (0..96) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 96.; + [center[0] + radius * a.cos(), center[1] + radius * a.sin()] + }) + .collect() +} + +// An L with R2 convex corners and an R2 reentrant corner. The sampled polygon +// itself is the recipe, so there is no unstated curve approximation in its input. +fn boundary() -> Boundary { + let mut outer = Vec::new(); + for (center, start, sweep) in + [([4., 4.], 180., 90.), ([16., 4.], 270., 90.), ([16., 8.], 0., 90.), ([12., 12.], 270., -90.), ([8., 16.], 0., 90.), ([4., 16.], 90., 90.)] + { + for i in 0..=32 { + let a = (start + sweep * i as f64 / 32.) * std::f64::consts::PI / 180.; + outer.push([center[0] + 2. * a.cos(), center[1] + 2. * a.sin()]); + } + } + Boundary { outer, islands: vec![circle([6., 6.], 0.8), circle([6., 13.], 0.8)] } +} +fn ring(points: &[[f64; 2]]) -> Loop2 { + Loop2 { + segs: points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(points.len()) + .map(|(a, b)| Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) }) + .collect(), + } +} +pub(super) fn flat(out: &mut Vec<[Vec3; 3]>, outer: &[[f64; 2]], holes: &[Vec<[f64; 2]>], z: f64) { + let region = Region2 { outer: ring(outer), holes: holes.iter().map(|h| ring(h)).collect() }; + for t in region.triangulate(1e-6) { + if (t[1] - t[0]).cross(t[2] - t[0]).abs() > 1e-14 { + out.push(t.map(|p| Vec3::new(p.x, p.y, z))); + } + } +} +pub(super) fn wall(out: &mut Vec<[Vec3; 3]>, points: &[[f64; 2]], top: f64, bottom: f64) { + for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let (a, b) = (Vec3::new(a[0], a[1], top), Vec3::new(b[0], b[1], top)); + let (c, d) = (Vec3::new(a.x, a.y, bottom), Vec3::new(b.x, b.y, bottom)); + out.extend([[a, b, d], [a, d, c]]); + } +} +fn target(boundary: &Boundary, top: f64, drilled: bool) -> Vec<[Vec3; 3]> { + let mut out = Vec::new(); + flat(&mut out, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), top); + let mut holes = boundary.islands.clone(); + let drill = circle([15., 6.], 0.5); + if drilled { + holes.push(drill.clone()); + } + flat(&mut out, &boundary.outer, &holes, 4.); + wall(&mut out, &boundary.outer, top, 4.); + for island in &boundary.islands { + flat(&mut out, island, &[], top); + wall(&mut out, island, top, 4.); + } + if drilled { + let shoulder = 3. + 0.5 / 59_f64.to_radians().tan(); + wall(&mut out, &drill, 4., shoulder); + for (a, b) in drill.iter().zip(drill.iter().cycle().skip(1)).take(drill.len()) { + out.push([Vec3::new(15., 6., 3.), Vec3::new(a[0], a[1], shoulder), Vec3::new(b[0], b[1], shoulder)]); + } + } + out +} + +pub(super) fn fixture(name: &str) -> Result { + let mut input = super::fixture("pocket")?; + let mut boundary = boundary(); + input.setup.name = "Rounded L with two islands".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.tools[0].name = "2 mm center-cutting end mill".into(); + let mut pocket = input.operations[0].clone(); + pocket.name = "Concave pocket and islands".into(); + pocket.bottom = 4.; + pocket.stepdown = 0.5; + pocket.stepover = 0.75; + // Exercise winding normalization independently for outer and island loops. + boundary.outer.reverse(); + if let Some(first) = boundary.islands.first_mut() { + first.reverse(); + } + pocket.geometry = Strategy::Pocket { boundary: boundary.clone() }; + input.geometry.target = target(&boundary, if name == "multi-operation" { 5.5 } else { 6. }, name == "multi-operation"); + input.operations = vec![pocket.clone()]; + if name == "multi-operation" { + let mut face = super::fixture("face")?; + face.tools[0].id = 2; + face.tools[0].number = 2; + face.operations[0].id = 1; + face.operations[0].tool = 2; + face.operations[0].bottom = 5.5; + pocket.id = 2; + pocket.top = 5.5; + pocket.allowance = 0.2; + pocket.coolant = true; + let mut finish = pocket.clone(); + finish.id = 3; + finish.name = "Finish pocket".into(); + finish.allowance = 0.; + finish.climb = false; + let mut drill = super::fixture("drill")?; + drill.tools[0].id = 3; + drill.tools[0].number = 3; + drill.operations[0].id = 4; + drill.operations[0].tool = 3; + drill.operations[0].top = 4.; + drill.operations[0].bottom = 3.; + drill.operations[0].geometry = Strategy::Drill { holes: vec![[15., 6.]], peck: Some(0.3), full_diameter: false }; + input.tools.extend([face.tools.remove(0), drill.tools.remove(0)]); + input.operations = vec![face.operations.remove(0), pocket, finish, drill.operations.remove(0)]; + input.setup.frame.origin = Vec3::new(35., -20., 10.); + input.setup.work_offset = 55; + } + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn protected_target_volume_matches_the_original_recipe() { + // Polygonal R2 quarter arcs contain 32 equal angle segments; islands + // and the drill use 96. These areas come from triangle sector formulae. + let pocket_area = 176. + 256. * (std::f64::consts::PI / 64.).sin() - 96. * 0.8_f64.powi(2) * (std::f64::consts::TAU / 96.).sin(); + for name in ["concave-islands", "multi-operation"] { + let input = fixture(name).unwrap(); + let volume: f64 = + input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + let expected = if name == "concave-islands" { + 2400. - 2. * pocket_area + } else { + let hole_area = 12. * (std::f64::consts::TAU / 96.).sin(); + 2200. - 1.5 * pocket_area - hole_area * (1. - 1. / (3. * 59_f64.to_radians().tan())) + }; + assert!((volume - expected).abs() < 1e-8, "{name}: {volume} != {expected}"); + } + } + #[test] + fn extended_fixtures_certify_complete_protected_surfaces() { + for name in ["concave-islands", "multi-operation"] { + let input = fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (report.status, report.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "{name} {} cells {} work {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + for (xy, expected) in super::super::probes(name, input.operations.len()).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE, "{name} {xy:?}"); + } + // Adaptive drill-tip cells must form a conforming stock surface, + // including where their refinement meets the pocket floor. + verify::stock_triangles(&report, input.setup.stock.min.z).unwrap(); + } + } + #[test] + fn intermediate_roughing_is_allowed_but_missing_finishing_is_not() { + let mut input = fixture("multi-operation").unwrap(); + input.operations.truncate(2); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let rough = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (rough.status, rough.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "{:?}", + rough.findings.iter().take(5).collect::>() + ); + assert!((verify::probe_height(&input, &parsed, [6., 9.]).unwrap() - 4.2).abs() < 1e-8); + let mut finished = fixture("multi-operation").unwrap(); + finished.operations.truncate(3); + let missing = verify::simulate(&finished, &parsed, Default::default()).unwrap(); + assert_eq!(missing.finished_dimensions, verify::Status::Fail); + } + #[test] + fn removed_islands_wrong_tool_and_wrong_work_offset_are_rejected() { + let input = fixture("concave-islands").unwrap(); + let mut damaged = input.clone(); + if let Strategy::Pocket { boundary } = &mut damaged.operations[0].geometry { + boundary.islands.clear(); + } + let path = generate::generate(damaged).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let result = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(result.status, verify::Status::Fail); + assert_eq!(result.finished_dimensions, verify::Status::Fail); + let input = fixture("multi-operation").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let tools: Vec<_> = path.blocks.iter().filter_map(|b| if let Motion::Tool { number, .. } = b.motion { Some(number) } else { None }).collect(); + assert_eq!(tools, vec![2, 1, 1, 3]); + let code = nc::post(&path, &input.setup).unwrap(); + let wrong = nc::parse(&code.replace("T3 M6", "T1 M6"), &input.tools).unwrap(); + let report = verify::simulate(&input, &wrong, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + assert_eq!(report.finished_dimensions, verify::Status::Fail); + let wrong = nc::parse(&code.replace("G55", "G54"), &input.tools).unwrap(); + assert!(verify::simulate(&input, &wrong, Default::default()).is_err()); + } +} diff --git a/crates/cam/src/corpus/exterior.rs b/crates/cam/src/corpus/exterior.rs new file mode 100644 index 00000000..d3164f50 --- /dev/null +++ b/crates/cam/src/corpus/exterior.rs @@ -0,0 +1,84 @@ +//! Outside contour of a square boss, with allowance roughing then finishing. +use crate::*; + +pub(super) fn fixture() -> Result { + let mut input = super::fixture("contour")?; + input.setup.name = "External square boss".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.tools[0].name = "2 mm center-cutting end mill".into(); + let square = vec![[5., 5.], [15., 5.], [15., 15.], [5., 15.]]; + let mut target = Vec::new(); + super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&square), 4.); + super::extended::flat(&mut target, &square, &[], 6.); + super::extended::wall(&mut target, &square, 6., 4.); + input.geometry.target = target; + let mut rough = input.operations[0].clone(); + rough.name = "External rough contour".into(); + rough.geometry = Strategy::Contour { boundary: Boundary { outer: square.into_iter().rev().collect(), islands: vec![] }, inside: false }; + rough.bottom = 4.; + rough.stepdown = 0.5; + rough.stepover = 1.; + rough.allowance = 0.2; + rough.lead_distance = 0.5; + rough.ramp_angle = Some(5_f64.to_radians()); + let mut finish = rough.clone(); + finish.id = 2; + finish.name = "External finish contour".into(); + finish.allowance = 0.; + finish.climb = false; + finish.coolant = true; + input.operations = vec![rough, finish]; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn external_contour_finishes_walls_with_leads_and_multiple_depths() { + let input = fixture().unwrap(); + let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - 1800.).abs() < 1e-9); + let path = generate::generate(input.clone()).unwrap(); + assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Arc { .. }))); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (report.status, report.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "cells={} work={} blocks={} {:?}", + report.cells.len(), + report.work, + path.blocks.len(), + report.findings + ); + for (xy, expected) in super::super::probes("external-contour", 2).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + } + + #[test] + fn wrong_contour_side_gouges_and_omitted_finish_leaves_allowance() { + let input = fixture().unwrap(); + let mut wrong = input.clone(); + wrong.operations.truncate(1); + if let Strategy::Contour { inside, .. } = &mut wrong.operations[0].geometry { + *inside = true; + } + let path = generate::generate(wrong).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + let mut rough = input.clone(); + rough.operations.truncate(1); + let path = generate::generate(rough).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, verify::Status::Fail, "cells={} work={} {:?}", report.cells.len(), report.work, report.findings); + } +} diff --git a/crates/cam/src/corpus/through.rs b/crates/cam/src/corpus/through.rs new file mode 100644 index 00000000..caf88a2b --- /dev/null +++ b/crates/cam/src/corpus/through.rs @@ -0,0 +1,94 @@ +//! Two through holes in a plate with a raised clamp between their axes. +//! Geometry and analytic expectations are independent of toolpath generation. +use crate::*; +use solidcraft_geom::Vec3; + +pub(super) fn fixture() -> Result { + let mut input = super::fixture("drill")?; + input.setup.name = "Clamped through-hole plate".into(); + input.setup.fixtures = vec![BodyRef { body: "Center clamp".into(), occurrence: vec![] }]; + let mut simple = input.operations[0].clone(); + simple.name = "Simple through hole".into(); + simple.bottom = -0.25; + simple.geometry = Strategy::Drill { holes: vec![[6., 10.]], peck: None, full_diameter: true }; + let mut peck = simple.clone(); + peck.id = 2; + peck.name = "Peck through hole".into(); + peck.coolant = true; + peck.geometry = Strategy::Drill { holes: vec![[14., 10.]], peck: Some(2.), full_diameter: true }; + input.operations = vec![simple, peck]; + let holes: Vec> = [6., 14.] + .into_iter() + .map(|x| { + (0..96) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 96.; + [x + 0.5 * a.cos(), 10. + 0.5 * a.sin()] + }) + .collect() + }) + .collect(); + let mut target = Vec::new(); + super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], &holes, 6.); + for hole in &holes { + super::extended::wall(&mut target, hole, 6., 0.); + } + input.geometry.target = target; + // Closed, outward-oriented 2 x 2 x 2 mm clamp on the plate top. A + // clearance-height transfer passes over it; a transfer at Z7 cannot. + let p = [[9., 9., 6.], [11., 9., 6.], [11., 11., 6.], [9., 11., 6.], [9., 9., 8.], [11., 9., 8.], [11., 11., 8.], [9., 11., 8.]].map(Vec3::from); + input.geometry.fixtures = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] + .into_iter() + .flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]]) + .collect(); + input.geometry.fixture_groups = vec![12]; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn clamped_through_holes_preserve_surfaces_and_export_open_bores() { + let input = fixture().unwrap(); + // Each inscribed 96-gon has area 96/2 * r^2 * sin(2pi/96). + let expected = 2400. - 6. * 24. * (std::f64::consts::TAU / 96.).sin(); + let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - expected).abs() < 1e-8); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!((report.status, report.finished_dimensions), (verify::Status::Pass, verify::Status::Pass), "{:?}", report.findings); + for (xy, expected) in super::super::probes("clamped-through-holes", 2).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + let stock = verify::stock_stl(&report, 0.).unwrap(); + let mesh = compare::read_stl(stock.as_bytes()).unwrap(); + assert_eq!(compare::stock_mesh(&report, &mesh, 0., TOLERANCE).unwrap().status, verify::Status::Pass); + } + + #[test] + fn missing_breakthrough_and_low_clamp_transfer_do_not_pass() { + let input = fixture().unwrap(); + // Authored NC: the tip reaches the bottom but the full diameter does + // not. Both bores retain a cone of material and must fail dimensions. + let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z0 F100\nG0 Z15\nG0 X14\nG1 Z0 F100\nG0 Z15\nM5\nM30"; + let parsed = nc::parse(code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, verify::Status::Fail); + // An otherwise clear rapid at Z7 crosses the raised clamp between + // axes. Require the actual source line, not merely a non-pass status. + let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nG0 Z7\nG0 X14\nG0 Z15\nM30"; + let parsed = nc::parse(code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + assert!(report.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(5))); + let mut short = input; + short.tools[0].cutting_length = 6.; + assert!(generate::generate(short).is_err()); + } +} diff --git a/crates/cam/src/coverage.rs b/crates/cam/src/coverage.rs new file mode 100644 index 00000000..e6916758 --- /dev/null +++ b/crates/cam/src/coverage.rs @@ -0,0 +1,215 @@ +//! Coverage certificates use boundary incidence, never summed overlapping area. +use crate::{spatial::Index, verify::Cell, *}; +use solidcraft_geom::Vec3; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Coord(f64); +impl Eq for Coord {} +impl PartialOrd for Coord { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Coord { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) } + } +} +type Point = [Coord; 2]; +fn xy(p: Vec3) -> Point { + [Coord(p.x), Coord(p.y)] +} +fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 { + robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) +} +fn corners(min: [f64; 2], max: [f64; 2]) -> [[f64; 2]; 4] { + [min, [max[0], min[1]], max, [min[0], max[1]]] +} +fn predicate_coordinates(values: impl IntoIterator) -> Result<()> { + // Avoid underflow in adaptive predicate expansions. Such coordinates are + // far below the supported machining error budget; reject, never snap. + if values.into_iter().any(|v| v != 0. && v.abs() < 1e-100) { + return invalid("comparison coordinates are below the predicate numeric range"); + } + Ok(()) +} + +/// Reject overlaps and hostile intervals; certify exact rectangular tiling in +/// O(n log n). Missing strips of any width stay missing, with no area epsilon. +pub(crate) fn cells(cells: &[Cell], stock: Stock) -> Result { + stock.validate()?; + if cells.is_empty() || cells.len() > 1_000_000 { + return invalid("invalid comparison cell count"); + } + let mut events = Vec::with_capacity(cells.len() * 2); + for (id, c) in cells.iter().enumerate() { + for p in [Vec3::new(c.min[0], c.min[1], c.low), Vec3::new(c.max[0], c.max[1], c.high)] { + model::point(p)?; + } + predicate_coordinates(c.min.into_iter().chain(c.max))?; + if c.min[0] >= c.max[0] + || c.min[1] >= c.max[1] + || c.low > c.high + || c.low < stock.min.z + || c.high > stock.max.z + || c.min[0] < stock.min.x + || c.min[1] < stock.min.y + || c.max[0] > stock.max.x + || c.max[1] > stock.max.y + { + return invalid("comparison cell outside declared stock or invalid interval"); + } + events.push((Coord(c.min[0]), true, id)); + events.push((Coord(c.max[0]), false, id)); + } + events.sort_unstable(); + let mut active = BTreeMap::::new(); + let mut gaps = 1isize; + let mut previous = stock.min.x; + let mut complete = true; + for (at, start, id) in events { + if at.0 > previous && gaps != 0 { + complete = false; + } + previous = at.0; + let c = &cells[id]; + let a = Coord(c.min[1]); + let b = Coord(c.max[1]); + if start { + let left = active.range(..=a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end); + let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start); + if left > a || right < b { + return invalid("overlapping comparison cells"); + } + gaps += isize::from(left != a) + isize::from(b != right) - isize::from(left != right); + active.insert(a, b); + } else { + if active.remove(&a) != Some(b) { + return invalid("invalid comparison cell sweep"); + } + let left = active.range(..a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end); + let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start); + gaps += isize::from(left != right) - isize::from(left != a) - isize::from(b != right); + } + } + Ok(complete && previous == stock.max.x) +} + +pub(crate) struct MeshField<'a> { + triangles: &'a [[Vec3; 3]], + index: Index, + boundary: Vec<([f64; 2], [f64; 2])>, + boundary_index: Index, + stock: Stock, + guard: f64, +} +impl<'a> MeshField<'a> { + pub(crate) fn new(triangles: &'a [[Vec3; 3]], stock: Stock) -> Result { + stock.validate()?; + crate::stock_mesh::validate_triangles(triangles)?; + let mut edges = BTreeMap::<(Point, Point), i32>::new(); + let mut items = Vec::new(); + let extent = stock.min.len().max(stock.max.len()).max(1.); + for (id, t) in triangles.iter().enumerate() { + for p in t { + predicate_coordinates([p.x, p.y])?; + if p.x < stock.min.x || p.y < stock.min.y || p.z < stock.min.z || p.x > stock.max.x || p.y > stock.max.y || p.z > stock.max.z { + return invalid("reference stock mesh exceeds declared stock; check units and frame"); + } + } + let p = t.map(|v| [v.x, v.y]); + let orientation = orient(p[0], p[1], p[2]); + if orientation < 0. && t.iter().any(|p| p.z != stock.min.z) { + return invalid("reference stock is not a bottom-supported vertical height field"); + } + if orientation <= 0. { + continue; + } + let min = t.iter().copied().fold(t[0], Vec3::min); + let max = t.iter().copied().fold(t[0], Vec3::max); + items.push((id, [min.x, min.y], [max.x, max.y])); + for (a, b) in [(xy(t[0]), xy(t[1])), (xy(t[1]), xy(t[2])), (xy(t[2]), xy(t[0]))] { + let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) }; + *edges.entry(key).or_default() += sign; + } + } + let boundary: Vec<_> = edges.into_iter().filter(|(_, n)| *n != 0).map(|((a, b), _)| (a.map(|v| v.0), b.map(|v| v.0))).collect(); + let boundary_index = Index::new( + boundary.iter().enumerate().map(|(id, (a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(), + ); + Ok(Self { triangles, index: Index::new(items), boundary, boundary_index, stock, guard: 128. * f64::EPSILON * extent }) + } + /// Bounds the maximum upward surface everywhere in a rectangle, including + /// holes (height = stock bottom). Shared projected edges cancel exactly; + /// unmatched edges crossing the interior forbid a full-coverage certificate. + pub(crate) fn bounds(&self, min: [f64; 2], max: [f64; 2], work: &mut usize, budget: usize) -> Option<[f64; 2]> { + let rectangle = corners(min, max); + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + let mut ids = Vec::new(); + self.boundary_index.query(min, max, &mut ids); + let mut crossed = false; + for id in ids { + *work += 1; + if *work > budget { + return None; + } + let (a, b) = self.boundary[id]; + if (0..2).any(|k| a[k].max(b[k]) <= min[k] || a[k].min(b[k]) >= max[k]) { + continue; + } + let signs = rectangle.map(|c| orient(a, b, c)); + if signs.iter().any(|v| *v < 0.) && signs.iter().any(|v| *v > 0.) { + crossed = true; + break; + } + } + let mut ids = Vec::new(); + self.index.query(min, max, &mut ids); + let mut low = f64::INFINITY; + let mut high = self.stock.min.z; + let mut inside = false; + for id in ids { + *work += 1; + if *work > budget { + return None; + } + let t = &self.triangles[id]; + let p = t.map(|p| [p.x, p.y]); + // These cells form a tiling whose closed columns are united later. + // A triangle touching only a cell edge belongs to the adjacent cell; + // including its height here creates false interior discontinuities. + // Positive-area overlap is decided with exact orientation predicates. + if (0..2).any(|k| p.iter().all(|v| v[k] <= min[k]) || p.iter().all(|v| v[k] >= max[k])) { + continue; + } + let edges = [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])]; + if edges.iter().any(|(a, b)| rectangle.iter().all(|c| orient(*a, *b, *c) <= 0.)) { + continue; + } + inside |= edges.iter().all(|(a, b)| orient(*a, *b, center) >= 0.); + let mut lo = t.iter().map(|v| v.z).fold(f64::INFINITY, f64::min); + let mut hi = t.iter().map(|v| v.z).fold(f64::NEG_INFINITY, f64::max); + let n = (t[1] - t[0]).cross(t[2] - t[0]); + let normal_error = 4. * self.guard * (1. + (t[1] - t[0]).len() + (t[2] - t[0]).len()); + if n.z > normal_error { + let slope = [n.x / n.z, n.y / n.z]; + let slope_error = slope.map(|s| normal_error * (1. + s.abs()) / (n.z - normal_error)); + let error = self.guard * (1. + slope[0].abs() + slope[1].abs()) + + slope_error[0] * (t[0].x - min[0]).abs().max((t[0].x - max[0]).abs()) + + slope_error[1] * (t[0].y - min[1]).abs().max((t[0].y - max[1]).abs()); + let z = rectangle.map(|p| t[0].z + slope[0] * (t[0].x - p[0]) + slope[1] * (t[0].y - p[1])); + if error.is_finite() && z.iter().all(|v| v.is_finite()) { + lo = lo.max(z.iter().copied().fold(f64::INFINITY, f64::min) - error); + hi = hi.min(z.iter().copied().fold(f64::NEG_INFINITY, f64::max) + error); + } + } + low = low.min(lo); + high = high.max(hi); + } + if !inside || crossed { + low = self.stock.min.z; + } + Some([(low - self.guard).max(self.stock.min.z), (high + self.guard).min(self.stock.max.z)]) + } +} diff --git a/crates/cam/src/dimensions.rs b/crates/cam/src/dimensions.rs new file mode 100644 index 00000000..ea7b064c --- /dev/null +++ b/crates/cam/src/dimensions.rs @@ -0,0 +1,554 @@ +//! Independent analytic finished-height envelopes. No path-generation or offset +//! code is used here. Radial bands limit diameter error; depth is checked in Z. +use crate::{verify::Status, *}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Envelope { + lower_min: f64, + lower_max: f64, + upper_min: f64, + upper_max: f64, +} +impl Envelope { + fn range(lo: f64, hi: f64, tolerance: f64) -> Self { + Self { lower_min: lo - tolerance, lower_max: hi - tolerance, upper_min: lo + tolerance, upper_max: hi + tolerance } + } + fn alternatives(self, b: Self) -> Self { + Self { + lower_min: self.lower_min.min(b.lower_min), + lower_max: self.lower_max.max(b.lower_max), + upper_min: self.upper_min.min(b.upper_min), + upper_max: self.upper_max.max(b.upper_max), + } + } + fn cut(self, b: Self) -> Self { + Self { + lower_min: self.lower_min.min(b.lower_min), + lower_max: self.lower_max.min(b.lower_max), + upper_min: self.upper_min.min(b.upper_min), + upper_max: self.upper_max.min(b.upper_max), + } + } + pub(crate) fn classify(self, low: f64, high: f64) -> Status { + if high < self.lower_min || low > self.upper_max { + Status::Fail + } else if low >= self.lower_max && high <= self.upper_min { + Status::Pass + } else { + Status::Inconclusive + } + } + pub(crate) fn description(self) -> String { + format!( + "Finished-region height must lie within [{:.6}, {:.6}] mm; cell-wide certified range [{:.6}, {:.6}] mm", + self.lower_min, self.upper_max, self.lower_max, self.upper_min + ) + } +} + +enum Feature { + Plane(f64), + Hole { center: [f64; 2], radius: f64, tip: f64, slope: f64 }, + Mill { region: crate::region::Region, outside: bool, width: Option, allowance: f64, depth: f64 }, +} +pub(crate) struct Finished { + features: Vec, + bottom: f64, + top: f64, + tolerance: f64, + radial_tolerance: f64, + pub(crate) refinement: f64, +} +impl Finished { + pub(crate) fn new(input: &JobInput, tolerance: f64) -> Result> { + // Reserve radial error on both sides of a diameter, and convert XY + // interpolation error into a conservative cone-height error as well. + let has_walls = input.operations.iter().any(|o| !matches!(o.geometry, Strategy::Face)); + let profile_error = input.operations.iter().filter(|o| o.source.is_some()).map(|o| crate::profile_tolerance(o.tolerance)).fold(0.0, f64::max); + let radial_tolerance = tolerance / 2.0 - POST_ERROR - profile_error; + let slope = input + .operations + .iter() + .filter(|o| matches!(o.geometry, Strategy::Drill { .. })) + .filter_map(|o| input.tools.iter().find(|t| t.id == o.tool)) + .map(|t| 1.0 / (t.tip_angle / 2.0).tan()) + .fold(0.0, f64::max); + let tolerance = tolerance - POST_ERROR * (1.0 + slope); + if tolerance <= 0.0 || (has_walls && radial_tolerance <= 0.0) { + return Ok(None); + } + let mut features = Vec::new(); + for op in &input.operations { + match &op.geometry { + Strategy::Face => features.push(Feature::Plane(op.bottom + op.allowance)), + Strategy::Drill { holes, full_diameter, .. } => { + let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?; + let tip = op.bottom - if *full_diameter { tool.tip_length() } else { 0.0 }; + for center in holes { + if features.len() >= MAX_MOVES { + return invalid("dimensional feature budget exceeded"); + } + features.push(Feature::Hole { center: *center, radius: tool.diameter / 2.0, tip, slope: 1.0 / (tool.tip_angle / 2.0).tan() }); + } + } + Strategy::Contour { boundary, inside } => { + if !boundary.islands.is_empty() { + return invalid("select contour islands as separate operations"); + } + let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?; + features.push(Feature::Mill { + region: crate::region::Region::new(boundary)?, + outside: !inside, + width: Some(tool.diameter / 2.), + allowance: op.allowance, + depth: op.bottom + op.allowance, + }); + } + Strategy::Pocket { boundary } => features.push(Feature::Mill { + region: crate::region::Region::new(boundary)?, + outside: false, + width: None, + allowance: op.allowance, + depth: op.bottom + op.allowance, + }), + } + } + Ok(Some(Self { + features, + bottom: input.setup.stock.min.z, + top: input.setup.stock.max.z, + tolerance, + radial_tolerance, + refinement: if has_walls { VERIFY_ERROR.min(tolerance / 8.0).min(radial_tolerance / 4.0) } else { VERIFY_ERROR }, + })) + } + pub(crate) async fn envelope>( + &self, + region: ([f64; 2], f64), + work: &mut usize, + budget: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let (p, half) = region; + let unchanged = Envelope::range(self.top, self.top, self.tolerance); + let mut combined = unchanged; + for (index, feature) in self.features.iter().enumerate() { + if index % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let allowed = match feature { + Feature::Plane(z) => { + let z = z.clamp(self.bottom, self.top); + Envelope::range(z, z, self.tolerance) + } + Feature::Mill { region, outside, width, allowance, depth } => { + let reach = allowance + width.unwrap_or(0.) + self.radial_tolerance; + let Some(mut d) = region.range((p, half, reach), work, budget, cancelled, yield_control).await? else { + return Ok(None); + }; + if *outside { + d = [-d[1], -d[0]]; + } + let floor = Envelope::range(depth.clamp(self.bottom, self.top), depth.clamp(self.bottom, self.top), self.tolerance); + // A contour finishes the wall and adjacent floor out to one + // tool radius. Farther stock on the cutting side is optional + // roughing stock; it must never be cut below the stated floor. + let optional = Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..floor }; + let mut possible: Option = None; + let mut add = |v| possible = Some(possible.map_or(v, |old| old.alternatives(v))); + if d[0] <= allowance - self.radial_tolerance { + add(unchanged); + } + if d[1] >= allowance - self.radial_tolerance && d[0] <= allowance + self.radial_tolerance { + add(optional); + } + if d[1] >= allowance + self.radial_tolerance && width.is_none_or(|w| d[0] <= allowance + w) { + add(floor); + } + if width.is_some_and(|w| d[1] >= allowance + w) { + add(optional); + } + let Some(allowed) = possible else { + return Ok(None); + }; + allowed + } + Feature::Hole { center, radius, tip, slope } => { + let r = (p[0] - center[0]).hypot(p[1] - center[1]); + let lo = (r - half).max(0.0); + let hi = r + half; + let zlo = (tip + lo.min(*radius) * slope).clamp(self.bottom, self.top); + let zhi = (tip + hi.min(*radius) * slope).clamp(self.bottom, self.top); + let cut = Envelope::range(zlo, zhi, self.tolerance); + let mut possible: Option = None; + let mut add = |v| { + possible = Some(possible.map_or(v, |old| old.alternatives(v))); + }; + if lo <= radius - self.radial_tolerance { + add(cut); + } + if hi >= radius + self.radial_tolerance { + add(unchanged); + } + if hi >= radius - self.radial_tolerance && lo <= radius + self.radial_tolerance { + add(Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..cut }); + } + let Some(allowed) = possible else { + return Ok(None); + }; + allowed + } + }; + combined = combined.cut(allowed); + } + Ok(Some(combined)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn envelope(f: &Finished, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> Option { + use std::future::Future; + let mut ready = || std::future::ready(()); + let mut future = std::pin::pin!(f.envelope((p, half), work, budget, &|| false, &mut ready)); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result.unwrap(), + _ => None, + } + } + fn finished() -> Finished { + Finished::new(&corpus::fixture("drill").unwrap(), TOLERANCE).unwrap().unwrap() + } + #[test] + fn generated_contours_and_pockets_certify_selected_finished_regions() { + for name in ["contour", "pocket"] { + let input = corpus::fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let report = simulate(&input, &code); + assert_eq!( + report.status, + Status::Pass, + "{name} cells {} work {} {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + assert_eq!( + report.finished_dimensions, + Status::Pass, + "{name} cells {} work {} {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + } + } + #[test] + fn missed_milling_wrong_floor_and_inaccessible_corners_fail() { + for name in ["contour", "pocket"] { + let input = corpus::fixture(name).unwrap(); + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let wrong = simulate(&input, &code.replace("Z5.000000", "Z5.100000")); + assert_eq!(wrong.finished_dimensions, Status::Fail, "{name}"); + let missed = simulate(&input, "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n"); + assert_eq!(missed.finished_dimensions, Status::Fail, "{name}"); + } + let mut input = corpus::fixture("pocket").unwrap(); + input.operations[0].geometry = + Strategy::Pocket { boundary: Boundary { outer: vec![[2., 2.], [18., 2.], [18., 18.], [2., 18.]], islands: vec![] } }; + // A round cutter cannot finish sharp internal corners. Protected geometry + // is independently relaxed here to isolate the dimensional failure. + for t in &mut input.geometry.target { + for p in t { + p.z = 0.; + } + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail); + } + #[test] + fn allowance_winding_multiple_depths_and_external_contours() { + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].allowance = 0.2; + input.operations[0].stepdown = 0.3; + if let Strategy::Contour { boundary, .. } = &mut input.operations[0].geometry { + boundary.outer.reverse(); + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings); + if let Strategy::Contour { inside, .. } = &mut input.operations[0].geometry { + *inside = false; + } + // The separate protected-CAD tests exercise real walls; this case checks + // only the independent outside/inside selection and allowance contract. + for t in &mut input.geometry.target { + for p in t { + p.z = 0.; + } + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings); + } + #[test] + fn pocket_islands_are_protected_and_finished() { + let mut input = corpus::fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry { + boundary.islands.push( + (0..48) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 48.; + [10. + a.cos(), 10. + a.sin()] + }) + .collect(), + ); + } + input.tools[0].diameter = 2.; + input.operations[0].stepover = 0.75; + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!( + r.finished_dimensions, + Status::Pass, + "cells {} work {} {:?}", + r.cells.len(), + r.work, + r.findings.iter().take(3).collect::>() + ); + let mut without = input.clone(); + if let Strategy::Pocket { boundary } = &mut without.operations[0].geometry { + boundary.islands.clear(); + } + let code = nc::post(&generate::generate(without.clone()).unwrap(), &without.setup).unwrap(); + assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail); + if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry { + boundary.islands = [8., 12.] + .into_iter() + .map(|x| { + (0..48) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 48.; + [x + 0.8 * a.cos(), 10. + 0.8 * a.sin()] + }) + .collect() + }) + .collect(); + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!( + r.finished_dimensions, + Status::Pass, + "cells {} work {} {:?}", + r.cells.len(), + r.work, + r.findings.iter().take(3).collect::>() + ); + } + #[test] + fn associated_profile_chord_error_has_a_separate_budget() { + let mut input = corpus::fixture("pocket").unwrap(); + input.operations[0].source = Some(ProfileRef { sketch: 1, curves: vec!["wall".into()], occurrence: vec![] }); + let f = Finished::new(&input, TOLERANCE).unwrap().unwrap(); + assert!((f.radial_tolerance - (TOLERANCE / 2. - POST_ERROR - crate::profile_tolerance(TOLERANCE))).abs() < 1e-12); + assert_eq!(crate::profile_tolerance(TOLERANCE), 0.0005); + // A looser operation can coexist with a tighter program tolerance. Use + // the actual sampling budget of each source, not the program minimum. + input.operations[0].tolerance = 0.1; + let f = Finished::new(&input, 0.005).unwrap().unwrap(); + assert!((f.radial_tolerance - 0.001).abs() < 1e-12); + let mut unused = input.tools[0].clone(); + unused.id = 2; + unused.number = 2; + unused.kind = ToolKind::TwistDrill; + unused.tip_angle = 0.11; + input.tools.push(unused); + assert!(Finished::new(&input, 0.005).unwrap().is_some()); + } + #[test] + fn wrong_milling_diameter_and_shifted_paths_are_detected() { + let mut input = corpus::fixture("contour").unwrap(); + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + for diameter in [5.8, 6.2] { + let mut other = input.tools[0].clone(); + other.id = 2; + other.number = 2; + other.diameter = diameter; + input.tools.truncate(1); + input.tools.push(other); + assert_eq!(simulate(&input, &code.replace("T1 M6", "T2 M6")).finished_dimensions, Status::Fail); + } + let mut shifted = nc::parse(&code, &input.tools).unwrap(); + for record in &mut shifted.records { + match &mut record.motion { + Motion::Rapid { to } | Motion::Linear { to, .. } => to.x += 0.03, + Motion::Arc { to, center, .. } => { + to.x += 0.03; + center.x += 0.03; + } + _ => {} + } + } + assert_eq!(verify::simulate(&input, &shifted, Default::default()).unwrap().finished_dimensions, Status::Fail); + } + #[test] + fn hand_calculated_cone_and_diameter_band() { + let f = finished(); + let mut work = 0; + assert_eq!(envelope(&f, [10., 10.], 0., &mut work, 100).unwrap().classify(5., 5.), Status::Pass); + let z = 5. + 0.25 / (59_f64.to_radians().tan()); + assert_eq!(envelope(&f, [10.25, 10.], 0., &mut work, 100).unwrap().classify(z, z), Status::Pass); + assert_eq!(envelope(&f, [10.49, 10.], 0., &mut work, 100).unwrap().classify(6., 6.), Status::Fail); + assert_eq!(envelope(&f, [10.51, 10.], 0., &mut work, 100).unwrap().classify(5.3, 5.3), Status::Fail); + assert_eq!(envelope(&f, [10.5, 10.], 0., &mut work, 100).unwrap().classify(5.31, 6.), Status::Pass); + } + #[test] + fn boundary_uncertainty_and_resource_limits_never_pass() { + let f = finished(); + let mut work = 0; + assert_eq!(envelope(&f, [10.5, 10.], 0.01, &mut work, 100).unwrap().classify(5.3, 6.), Status::Inconclusive); + assert!(envelope(&f, [10., 10.], 0., &mut work, 0).is_none()); + } + fn program(tool: u32, x: f64, depth: f64) -> String { + format!( + "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nT{tool} M6\nS6000 M3\nM9\nG0 X{x} Y10 Z15\nG1 Z6 F100\nG1 Z{depth} F100\nG0 Z15\nM5\nM9\nM30\n" + ) + } + fn simulate(input: &JobInput, code: &str) -> crate::verify::Report { + let parsed = nc::parse(code, &input.tools).unwrap(); + verify::simulate(input, &parsed, Default::default()).unwrap() + } + #[test] + fn blind_hole_certifies_and_bad_depth_position_diameter_fail() { + let mut input = corpus::fixture("drill").unwrap(); + let correct = simulate(&input, &program(1, 10., 5.)); + assert_eq!(correct.status, Status::Pass); + assert_eq!(correct.finished_dimensions, Status::Pass, "{:?}", correct.findings); + for (x, depth) in [(10., 5.1), (10., 4.9), (10.03, 5.)] { + assert_eq!(simulate(&input, &program(1, x, depth)).finished_dimensions, Status::Fail); + } + for diameter in [0.8, 1.2] { + let mut other = input.tools[0].clone(); + other.id = 2; + other.number = 2; + other.diameter = diameter; + other.shank_diameter = diameter; + input.tools.truncate(1); + input.tools.push(other); + assert_eq!(simulate(&input, &program(2, 10., 5.)).finished_dimensions, Status::Fail); + } + } + #[test] + fn absent_hole_is_not_hidden_by_a_coarse_stock_cell() { + let input = corpus::fixture("drill").unwrap(); + let code = "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n"; + let report = simulate(&input, code); + assert_eq!(report.finished_dimensions, Status::Fail); + assert!(report.findings.iter().any(|f| f.kind == "finished_region_error")); + } + #[test] + fn full_diameter_blind_and_through_depths_are_certified() { + for bottom in [4.5, 0.] { + let mut input = corpus::fixture("drill").unwrap(); + input.operations[0].bottom = bottom; + input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.]], peck: None, full_diameter: true }; + for tri in &mut input.geometry.target { + for p in tri { + p.z = 0.; + } + } + let depth = bottom - input.tools[0].tip_length(); + let report = simulate(&input, &program(1, 10., depth)); + assert_eq!(report.status, Status::Pass, "{:?}", report.findings); + assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings); + } + } + #[test] + fn declared_drill_allowance_is_rejected_instead_of_ignored() { + let mut input = corpus::fixture("drill").unwrap(); + input.operations[0].allowance = 0.1; + assert!(input.validate().is_err()); + } + #[test] + fn facing_and_multiple_holes_compose_without_restoring_stock() { + let mut input = corpus::fixture("drill").unwrap(); + for t in &mut input.geometry.target { + for p in t { + p.z = 0.0; + } + } + input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.], [12., 10.]], peck: Some(0.3), full_diameter: false }; + let mut face = corpus::fixture("face").unwrap(); + face.tools[0].id = 2; + face.tools[0].number = 2; + face.operations[0].id = 2; + face.operations[0].tool = 2; + face.operations[0].bottom = 5.8; + input.tools.push(face.tools[0].clone()); + input.operations.push(face.operations[0].clone()); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let report = simulate(&input, &code); + assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings); + } + #[test] + fn dimensional_work_yields_and_cancels() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let mut f = finished(); + f.features = (0..5000).map(|_| Feature::Plane(5.)).collect(); + let cancel = std::cell::Cell::new(false); + let mut yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut work = 0; + let cancelled = || cancel.get(); + let mut future = std::pin::pin!(f.envelope(([10., 10.], 0.001), &mut work, 10000, &cancelled, &mut yield_once)); + let mut context = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut context).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_)))); + } + #[test] + fn equivalent_inch_and_mm_holes_certify_the_same_dimensions() { + let input = corpus::fixture("drill").unwrap(); + let inch = format!( + "G20 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z{}\nT1 M6\nS6000 M3\nM9\nG0 X{} Y{} Z{}\nG1 Z{} F{}\nG1 Z{}\nG0 Z{}\nM5\nM9\nM30\n", + 15. / 25.4, + 10. / 25.4, + 10. / 25.4, + 15. / 25.4, + 6. / 25.4, + 100. / 25.4, + 5. / 25.4, + 15. / 25.4 + ); + let metric = simulate(&input, &program(1, 10., 5.)); + let imperial = simulate(&input, &inch); + assert_eq!(imperial.finished_dimensions, Status::Pass); + assert!((imperial.cutting_distance - metric.cutting_distance).abs() < 1e-10); + assert!((imperial.remaining_volume_high - metric.remaining_volume_high).abs() < 1e-8); + } +} diff --git a/crates/cam/src/generate.rs b/crates/cam/src/generate.rs new file mode 100644 index 00000000..13f5a976 --- /dev/null +++ b/crates/cam/src/generate.rs @@ -0,0 +1,680 @@ +//! Toolpath construction. No verifier or NC interpreter algorithms live here. +use crate::*; +use cavalier_contours::polyline::{PlineSource, PlineSourceMut, Polyline, seg_arc_radius_and_center, seg_length}; +use cavalier_contours::shape_algorithms::{Shape, ShapeOffsetOptions}; +use solidcraft_geom::Vec3; + +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll, Waker}, +}; + +/// A portable job advances a bounded emission/validation chunk or one bounded +/// library offset query per poll. No executor, OS thread or browser API is used. +pub struct Generator { + pending: Option> + Send>>>, + path: Option, + progress: Arc, + failed: Option, + cancelled: bool, +} +impl Generator { + pub fn new(input: JobInput) -> Result { + input.validate()?; + if input.operations.is_empty() || input.operations.len() > MAX_ITEMS { + return invalid("empty or excessive operation list"); + } + let progress = Arc::new(AtomicUsize::new(0)); + let pending = Box::pin(generate_incremental(input, progress.clone())); + Ok(Self { pending: Some(pending), path: None, progress, failed: None, cancelled: false }) + } + pub fn cancel(&mut self) { + self.cancelled = true; + self.pending = None; + self.path = None; + } + pub fn progress(&self) -> f64 { + self.progress.load(Ordering::Relaxed) as f64 / 1_000_000. + } + pub fn step(&mut self) -> Result { + if self.cancelled { + return invalid("generation cancelled"); + } + if let Some(error) = &self.failed { + return invalid(error.clone()); + } + if self.path.is_some() { + return Ok(true); + } + let future = self.pending.as_mut().ok_or_else(|| Error("generation has no pending work".into()))?; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| future.as_mut().poll(&mut Context::from_waker(Waker::noop())))); + match outcome { + Ok(Poll::Pending) => Ok(false), + Ok(Poll::Ready(Ok(path))) => { + self.pending = None; + self.path = Some(path); + self.progress.store(1_000_000, Ordering::Relaxed); + Ok(true) + } + result => { + let error = match result { + Ok(Poll::Ready(Err(e))) => e, + _ => Error("contour library failed; no path published".into()), + }; + self.pending = None; + self.failed = Some(error.0.clone()); + Err(error) + } + } + } + pub fn finish(self) -> Result { + if self.cancelled { + return invalid("generation cancelled"); + } + self.path.ok_or_else(|| Error(self.failed.unwrap_or_else(|| "generation incomplete".into()))) + } +} +#[derive(Default)] +struct YieldOnce(bool); +impl Future for YieldOnce { + type Output = (); + fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> { + if self.0 { + Poll::Ready(()) + } else { + self.0 = true; + Poll::Pending + } + } +} +#[derive(Clone)] +struct Progress { + value: Arc, + operation: usize, + count: usize, +} +impl Progress { + fn set(&self, fraction: f64) { + self.value.fetch_max(((self.operation as f64 + fraction.clamp(0., 1.)) / self.count as f64 * 1_000_000.) as usize, Ordering::Relaxed); + } +} +async fn generate_incremental(input: JobInput, value: Arc) -> Result { + let mut path = Toolpath { fingerprint: input.fingerprint.clone(), blocks: Vec::new() }; + for (index, o) in input.operations.iter().enumerate() { + YieldOnce::default().await; + let progress = Progress { value: value.clone(), operation: index, count: input.operations.len() }; + let blocks = operation(&input, o, progress.clone()).await?; + if path.blocks.len() + blocks.len() > MAX_MOVES { + return invalid("toolpath exceeds motion limit"); + } + path.blocks.extend(blocks); + progress.set(1.); + } + Ok(path) +} +pub fn generate(input: JobInput) -> Result { + let mut g = Generator::new(input)?; + while !g.step()? {} + g.finish() +} +fn tool(i: &JobInput, id: u64) -> Result<&Tool> { + i.tools.iter().find(|t| t.id == id).ok_or_else(|| Error("unknown tool".into())) +} + +struct Writer { + blocks: Vec, + op: u64, + pos: Vec3, + progress: Progress, +} +impl Writer { + async fn push(&mut self, motion: Motion) -> Result<()> { + if self.blocks.len() >= MAX_MOVES { + return invalid("operation exceeds motion limit"); + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &motion { + crate::model::point(*to)?; + self.pos = *to; + } + self.blocks.push(Block { operation: self.op, motion }); + if self.blocks.len().is_multiple_of(256) { + self.progress.set(0.3 + 0.65 * self.blocks.len() as f64 / (self.blocks.len() as f64 + 1024.)); + YieldOnce::default().await; + } + Ok(()) + } + async fn rapid(&mut self, p: Vec3) -> Result<()> { + self.push(Motion::Rapid { to: p }).await + } + async fn feed(&mut self, p: Vec3, f: f64) -> Result<()> { + self.push(Motion::Linear { to: p, feed: f }).await + } + async fn enter(&mut self, s: &Setup, p: Vec3, o: &Operation) -> Result<()> { + self.rapid(Vec3::new(self.pos.x, self.pos.y, s.clearance)).await?; + self.rapid(Vec3::new(p.x, p.y, s.clearance)).await?; + self.rapid(Vec3::new(p.x, p.y, s.retract)).await?; + self.feed(p, o.plunge_feed).await + } +} +async fn operation(input: &JobInput, o: &Operation, progress: Progress) -> Result> { + let s = &input.setup; + let t = tool(input, o.tool)?; + let mut w = Writer { blocks: Vec::new(), op: o.id, pos: Vec3::new(0.0, 0.0, s.clearance), progress: progress.clone() }; + w.push(Motion::Tool { tool: t.id, number: t.number }).await?; + w.push(Motion::Spindle { rpm: o.spindle }).await?; + w.push(Motion::Coolant { on: o.coolant }).await?; + let levels = depths(o.top, o.bottom + o.allowance, o.stepdown)?; + match &o.geometry { + Strategy::Face => { + let (a, b) = (s.stock.min, s.stock.max); + let radius = t.diameter / 2.0; + let n = ((b.y - a.y) / o.stepover).ceil() as usize; + if n > 10_000 { + return invalid("facing exceeds pass limit"); + } + for z in levels { + for j in 0..=n { + let y = a.y + (b.y - a.y) * j as f64 / n.max(1) as f64; + // M3/right-handed cutter: climb keeps uncut material on the + // right. Rows advance toward +Y, so climb travels toward -X. + let (x0, x1) = if o.climb { (b.x + radius, a.x - radius) } else { (a.x - radius, b.x + radius) }; + w.enter(s, Vec3::new(x0, y, z), o).await?; + w.feed(Vec3::new(x1, y, z), o.feed).await?; + } + } + } + Strategy::Contour { boundary, inside } => { + if !boundary.islands.is_empty() { + return invalid("contour islands: select each contour as its own operation"); + } + let shape = shape(boundary, progress.clone()).await?; + let d = (t.diameter / 2.0 + o.allowance) * if *inside { 1.0 } else { -1.0 }; + YieldOnce::default().await; + let loops = shape.offset(d); + if loops.ccw_plines.is_empty() { + return invalid("tool cannot fit contour"); + } + for z in levels { + for lp in &loops.ccw_plines { + // Offsets are CCW. Climb is CCW inside a wall and CW outside a boss. + trace(&mut w, s, o, lp, z, o.climb != *inside).await?; + } + } + } + Strategy::Pocket { boundary } => { + let original = shape(boundary, progress.clone()).await?; + let mut loops = Vec::new(); + let mut vertices = 0usize; + let mut distance = t.diameter / 2.0 + o.allowance; + for pass in 0..10_000 { + progress.set(0.1 + 0.15 * pass as f64 / (pass as f64 + 8.)); + YieldOnce::default().await; + // A 1e-6 mm clearance reserve keeps exact offset + // tangencies away from the shape stitcher's degenerate slices. + // It is 1/1000 of the post budget; independent verification still + // certifies the unmodified requested region and allowance. + let offset = original.offset(distance + 1e-6); + if offset.ccw_plines.is_empty() { + break; + } + for lp in offset.ccw_plines.iter().chain(&offset.cw_plines) { + vertices = vertices.checked_add(lp.vertex_count()).ok_or_else(|| Error("pocket vertex budget exhausted".into()))?; + if vertices > MAX_MOVES { + return invalid("pocket vertex budget exhausted"); + } + loops.push(lp.clone()); + } + if loops.len() > 10_000 { + return invalid("pocket exceeds pass limit"); + } + distance += o.stepover; + if pass == 9999 { + return invalid("pocket offset budget exhausted"); + } + } + if loops.is_empty() { + return invalid("tool cannot fit pocket"); + } + // Innermost passes first; independent clearance retracts avoid island crossings. + for z in levels { + for lp in loops.iter().rev() { + // CCW outer loops and CW islands already put protected + // material on the right; reverse both for conventional cuts. + trace(&mut w, s, o, lp, z, !o.climb).await?; + } + } + } + Strategy::Drill { holes, peck, full_diameter } => { + let bottom = o.bottom - if *full_diameter { t.tip_length() } else { 0.0 }; + let zs = depths(o.top, bottom, peck.unwrap_or(o.top - bottom))?; + for p in holes { + w.enter(s, Vec3::new(p[0], p[1], o.top), o).await?; + let mut previous = o.top; + for z in &zs { + if previous != o.top { + w.feed(Vec3::new(p[0], p[1], previous), o.plunge_feed).await?; + } + w.feed(Vec3::new(p[0], p[1], *z), o.plunge_feed).await?; + w.rapid(Vec3::new(p[0], p[1], s.retract)).await?; + previous = *z; + } + } + } + } + w.rapid(Vec3::new(w.pos.x, w.pos.y, s.clearance)).await?; + w.push(Motion::Coolant { on: false }).await?; + w.push(Motion::Spindle { rpm: 0.0 }).await?; + w.push(Motion::Stop).await?; + Ok(w.blocks) +} +fn depths(top: f64, bottom: f64, step: f64) -> Result> { + if bottom >= top { + return invalid("allowance leaves no cutting depth"); + } + let n = ((top - bottom) / step).ceil() as usize; + if n > 10_000 { + return invalid("depth pass limit"); + } + Ok((1..=n).map(|i| (top - step * i as f64).max(bottom)).collect()) +} +async fn trace(w: &mut Writer, s: &Setup, o: &Operation, lp: &Polyline, z: f64, reverse: bool) -> Result<()> { + let mut lp = lp.clone(); + if reverse { + lp.invert_direction_mut(); + } + let start = lp.iter_vertexes().next().ok_or_else(|| Error("empty offset".into()))?; + let entry_z = if o.ramp_angle.is_some() { o.top } else { z }; + let first = Vec3::new(start.x, start.y, entry_z); + let tangent = lp.iter_segments().next().map(|(a, b)| Vec3::new(b.x - a.x, b.y - a.y, 0.0)).and_then(|v| v.normalized()).unwrap_or(Vec3::X); + w.enter(s, first - tangent * o.lead_distance, o).await?; + if o.lead_distance > 0.0 { + w.feed(first, o.feed).await?; + } + if let Some(angle) = o.ramp_angle { + let perimeter = lp.iter_segments().map(|(a, b)| seg_length(a, b)).sum::(); + if perimeter <= 1e-8 { + return invalid("ramp contour has no length"); + } + let laps = ((o.top - z) / (perimeter * angle.tan())).ceil() as usize; + if laps > 1000 { + return invalid("entry ramp exceeds lap budget"); + } + let mut height = o.top; + for _ in 0..laps { + for (a, b) in lp.iter_segments() { + let length = seg_length(a, b); + let bottom = (height - length * angle.tan()).max(z); + if a.bulge.abs() < 1e-12 || length <= POST_ERROR / 4. { + w.feed(Vec3::new(b.x, b.y, bottom), o.plunge_feed).await?; + } else { + let (radius, c) = seg_arc_radius_and_center(a, b); + let sweep = 4.0 * a.bulge.atan(); + let start = (a.y - c.y).atan2(a.x - c.x); + let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6); + let n = (sweep.abs() / step).ceil() as usize; + if n > MAX_MOVES { + return invalid("ramp arc exceeds motion budget"); + } + for k in 1..=n { + let t = k as f64 / n as f64; + let theta = start + sweep * t; + w.feed(Vec3::new(c.x + radius * theta.cos(), c.y + radius * theta.sin(), height + (bottom - height) * t), o.plunge_feed) + .await?; + } + } + height = bottom; + } + } + } + for (a, b) in lp.iter_segments() { + let to = Vec3::new(b.x, b.y, z); + // Offset topology may contain a vanishing arc at a tangency. Preserve + // its short motion as a line: rounded coincident G2/G3 endpoints would + // otherwise command a full circle. Its entire arc length bounds error. + if a.bulge.abs() < 1e-12 || seg_length(a, b) <= POST_ERROR / 4. { + w.feed(to, o.feed).await?; + } else { + let (_, c) = seg_arc_radius_and_center(a, b); + w.push(Motion::Arc { to, center: Vec3::new(c.x, c.y, z), clockwise: a.bulge < 0.0, feed: o.feed }).await?; + } + } + if o.lead_distance > 0.0 { + w.feed(Vec3::new(start.x, start.y, z) - tangent * o.lead_distance, o.feed).await?; + } + Ok(()) +} +// Work in setup millimetres with predicate/stitch tolerances comfortably below +// the post budget. Upstream defaults can merge distinct small offset slices. +struct OffsetShape { + shape: Shape, + scale: f64, +} +struct OffsetLoops { + ccw_plines: Vec>, + cw_plines: Vec>, +} +impl OffsetShape { + fn offset(&self, distance: f64) -> OffsetLoops { + let scale = self.scale; + let options = ShapeOffsetOptions { pos_equal_eps: 1e-8 * scale, offset_dist_eps: 1e-7 * scale, slice_join_eps: 1e-8 * scale }; + let result = self.shape.parallel_offset(distance * scale, &options); + let restore = |p: cavalier_contours::shape_algorithms::IndexedPolyline| { + let mut polyline = p.polyline; + polyline.scale_mut(1. / scale); + polyline + }; + OffsetLoops { + ccw_plines: result.ccw_plines.into_iter().map(restore).collect(), + cw_plines: result.cw_plines.into_iter().map(restore).collect(), + } + } +} +async fn shape(b: &Boundary, progress: Progress) -> Result { + b.validate()?; + // Reject all crossings/touches, not just those the offset library happens to tolerate. + let rings: Vec<_> = std::iter::once(&b.outer).chain(&b.islands).collect(); + let mut edges = Vec::new(); + let mut extent = 1_f64; + let mut shortest = f64::MAX; + for (ring, points) in rings.iter().enumerate() { + for vertex in 0..points.len() { + if edges.len().is_multiple_of(256) { + YieldOnce::default().await; + } + let a = points[vertex]; + let b = points[(vertex + 1) % points.len()]; + let c = points[(vertex + 2) % points.len()]; + if distance(a, b) < 1e-7 { + return invalid("zero-length boundary edge"); + } + shortest = shortest.min(distance(a, b)); + // Adjacent edges share their endpoint, but must not double back. + if robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) == 0. + && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. + { + return invalid("backtracking boundary edge"); + } + extent = extent.max(a[0].abs()).max(a[1].abs()); + edges.push((ring, vertex, a, b)); + } + } + YieldOnce::default().await; + let index = crate::spatial::Index::new( + edges.iter().enumerate().map(|(id, (_, _, a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(), + ); + // Include every pair admitted by the existing near-touch predicate. The + // numerical reserve also covers rounding when expanding coordinate bounds. + let padding = 1e-8 + 128. * f64::EPSILON * extent; + let mut comparisons = 0usize; + let mut candidates = Vec::new(); + for (id, &(ring, vertex, a, b)) in edges.iter().enumerate() { + if id.is_multiple_of(128) { + progress.set(0.1 * id as f64 / edges.len() as f64); + YieldOnce::default().await; + } + candidates.clear(); + index.query([a[0].min(b[0]) - padding, a[1].min(b[1]) - padding], [a[0].max(b[0]) + padding, a[1].max(b[1]) + padding], &mut candidates); + for other in candidates.iter().copied().filter(|other| *other > id) { + let (other_ring, other_vertex, c, d) = edges[other]; + let count = rings[ring].len(); + if ring == other_ring && ((vertex + 1) % count == other_vertex || (other_vertex + 1) % count == vertex) { + continue; + } + comparisons += 1; + if comparisons.is_multiple_of(4096) { + YieldOnce::default().await; + } + if comparisons > 2_000_000 { + return invalid("boundary validation budget exceeded"); + } + if intersects(a, b, c, d) { + return invalid("boundary intersections or touching loops"); + } + } + } + for (i, hole) in b.islands.iter().enumerate() { + YieldOnce::default().await; + if !inside(hole[0], &b.outer) || b.islands.iter().enumerate().any(|(j, h)| i != j && inside(hole[0], h)) { + return invalid("islands must be disjoint and inside the outer boundary"); + } + } + // Cavalier also applies its position epsilon to line cross products. Small + // segments can otherwise be classified as parallel (including a 10k-sided + // circle). Normalize the shortest input edge to at least one library unit. + // Powers of two preserve finite input coordinates exactly; no vertices are + // removed. Scale distance tolerances too, retaining their physical meaning. + let scale = 2_f64.powi((-shortest.log2()).ceil().max(0.) as i32); + let mut plines = Vec::new(); + for (i, ring) in rings.iter().enumerate() { + YieldOnce::default().await; + let mut p = Polyline::new_closed(); + for xy in ring.iter() { + p.add(xy[0], xy[1], 0.0); + } + if p.area().abs() < 1e-8 { + return invalid("zero-area boundary"); + } + if (p.area() > 0.0) != (i == 0) { + p.invert_direction_mut(); + } + p.scale_mut(scale); + plines.push(p); + } + Ok(OffsetShape { shape: Shape::from_plines(plines), scale }) +} +fn distance(a: [f64; 2], b: [f64; 2]) -> f64 { + (a[0] - b[0]).hypot(a[1] - b[1]) +} +fn cross(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) +} +fn intersects(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let eps = 1e-8; + let overlap = (0..2).all(|k| a[k].min(b[k]) <= c[k].max(d[k]) + eps && c[k].min(d[k]) <= a[k].max(b[k]) + eps); + overlap && cross(a, b, c) * cross(a, b, d) <= eps && cross(c, d, a) * cross(c, d, b) <= eps +} +fn inside(p: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut yes = false; + for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) { + if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] { + yes = !yes; + } + } + yes +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod incremental_tests { + use super::*; + fn polar(count: usize, lobes: usize) -> Vec<[f64; 2]> { + (0..count) + .map(|i| { + let angle = i as f64 * std::f64::consts::TAU / count as f64; + let radius = if lobes == 0 { 8. } else { 8. - 0.6 * (1. + (lobes as f64 * angle).cos()) }; + [10. + radius * angle.cos(), 10. + radius * angle.sin()] + }) + .collect() + } + fn validate_shape(boundary: &Boundary) -> Result { + let progress = Progress { value: Arc::new(AtomicUsize::new(0)), operation: 0, count: 1 }; + let mut future = std::pin::pin!(shape(boundary, progress)); + for _ in 0..10_000 { + if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + return result; + } + } + invalid("test shape did not complete within checkpoint bound") + } + #[test] + fn dense_simple_boundaries_and_one_hundred_islands_fit_validation_budget() { + for lobes in [0, 7] { + let mut boundary = Boundary { outer: polar(10_000, lobes), islands: vec![] }; + for _ in 0..2 { + let validated = validate_shape(&boundary).unwrap(); + assert_eq!(validated.shape.ccw_plines.len(), 1); + assert!(validated.shape.cw_plines.is_empty()); + boundary.outer.reverse(); + } + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].geometry = Strategy::Contour { boundary, inside: true }; + let path = generate(input).unwrap(); + assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Linear { .. } | Motion::Arc { .. }))); + } + let islands = (0..100) + .map(|n| { + (0..99) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 99.; + [5. + (n % 10) as f64 * 10. + a.cos(), 5. + (n / 10) as f64 * 10. + a.sin()] + }) + .collect() + }) + .collect(); + let boundary = Boundary { outer: vec![[0., 0.], [100., 0.], [100., 100.], [0., 100.]], islands }; + let validated = validate_shape(&boundary).unwrap(); + assert_eq!((validated.shape.ccw_plines.len(), validated.shape.cw_plines.len()), (1, 100)); + let offset = validated.offset(1.); + assert_eq!((offset.ccw_plines.len(), offset.cw_plines.len()), (1, 100)); + } + #[test] + fn normalized_offsets_preserve_dense_polygon_distance_and_coordinates() { + // Independent regular-polygon construction: inward offset preserves the + // apothem minus the cutter radius. Check every resulting vertex, not area. + for center in [[10., 10.], [-10_000., 20_000.]] { + for factor in [1., 0.125] { + let points = polar(10_000, 0).into_iter().map(|p| [center[0] + factor * (p[0] - 10.), center[1] + factor * (p[1] - 10.)]).collect(); + let boundary = Boundary { outer: points, islands: vec![] }; + let offset = validate_shape(&boundary).unwrap().offset(3. * factor); + assert_eq!(offset.ccw_plines.len(), 1); + assert!(offset.cw_plines.is_empty()); + let expected = factor * (8. - 3. / (std::f64::consts::PI / 10_000.).cos()); + for p in offset.ccw_plines[0].iter_vertexes() { + assert!(((p.x - center[0]).hypot(p.y - center[1]) - expected).abs() < 1e-7); + } + } + } + // Concave case: sample output vertices against all ORIGINAL input + // segments using an independently written point/segment projection. + let boundary = Boundary { outer: polar(10_000, 7), islands: vec![] }; + let offset = validate_shape(&boundary).unwrap().offset(3.); + assert_eq!(offset.ccw_plines.len(), 1); + for p in offset.ccw_plines[0].iter_vertexes().step_by(64) { + let nearest = boundary + .outer + .iter() + .zip(boundary.outer.iter().cycle().skip(1)) + .map(|(a, b)| { + let dx = b[0] - a[0]; + let dy = b[1] - a[1]; + let t = (((p.x - a[0]) * dx + (p.y - a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.); + (p.x - a[0] - t * dx).hypot(p.y - a[1] - t * dy) + }) + .fold(f64::INFINITY, f64::min); + assert!((nearest - 3.).abs() < 1e-7, "offset distance {nearest}"); + } + } + #[test] + fn indexed_validation_rejects_crossings_touches_and_backtracking() { + let square = |min: [f64; 2], max: [f64; 2]| vec![min, [max[0], min[1]], max, [min[0], max[1]]]; + for boundary in [ + Boundary { outer: vec![[0., 0.], [3., 3.], [0., 3.], [3., 0.]], islands: vec![] }, + Boundary { outer: vec![[0., 0.], [3., 0.], [1., 0.], [3., 3.], [0., 3.]], islands: vec![] }, + Boundary { outer: vec![[0., 0.], [3., 0.], [3., 0.], [0., 3.]], islands: vec![] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([0., 1.], [2., 3.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([5e-9, 1.], [2., 3.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [4., 4.]), square([4., 2.], [6., 4.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [8., 8.]), square([3., 3.], [4., 4.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([12., 2.], [14., 4.])] }, + ] { + assert!(validate_shape(&boundary).is_err(), "{boundary:?}"); + } + // A non-adjacent touch hidden among thousands of otherwise valid edges. + let mut boundary = Boundary { outer: polar(10_000, 0), islands: vec![] }; + boundary.outer[5000] = boundary.outer[0]; + assert!(validate_shape(&boundary).is_err()); + } + #[test] + fn cancellation_during_dense_boundary_construction_and_queries_publishes_nothing() { + for checkpoint in [1, 20, 80, 120] { + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: polar(10_000, 7), islands: vec![] }, inside: true }; + let mut generator = Generator::new(input).unwrap(); + for _ in 0..checkpoint { + assert!(!generator.step().unwrap()); + } + generator.cancel(); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } + } + #[test] + fn large_single_operations_yield_and_remain_deterministic() { + for name in ["face", "contour", "pocket", "drill"] { + let mut input = corpus::fixture(name).unwrap(); + input.operations[0].stepdown = 0.02; + if name == "drill" { + input.operations[0].geometry = Strategy::Drill { + holes: (0..300).map(|n| [1. + (n % 18) as f64, 1. + (n / 18) as f64]).collect(), + peck: Some(0.05), + full_diameter: false, + }; + } + let mut generator = Generator::new(input.clone()).unwrap(); + let mut polls = 0; + let mut previous = 0.; + let mut intermediate = false; + while !generator.step().unwrap() { + polls += 1; + let progress = generator.progress(); + assert!(progress >= previous && progress < 1.); + intermediate |= progress > 0.; + previous = progress; + assert!(polls < 100_000); + } + assert!(polls > 10 && intermediate, "{name}: {polls}"); + assert_eq!(generator.progress(), 1.); + let path = generator.finish().unwrap(); + assert_eq!(serde_json::to_value(path).unwrap(), serde_json::to_value(generate(input).unwrap()).unwrap()); + } + } + #[test] + fn cancellation_during_one_operation_discards_partial_output() { + let mut input = corpus::fixture("face").unwrap(); + input.operations[0].stepdown = 0.001; + let mut generator = Generator::new(input).unwrap(); + for _ in 0..4 { + assert!(!generator.step().unwrap()); + } + assert!(generator.progress() > 0. && generator.progress() < 1.); + generator.cancel(); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } + #[test] + fn incomplete_and_failed_jobs_never_publish_partial_paths() { + let mut generator = Generator::new(corpus::fixture("face").unwrap()).unwrap(); + assert!(!generator.step().unwrap()); + assert!(generator.finish().is_err()); + let mut input = corpus::fixture("face").unwrap(); + let mut bad = input.operations[0].clone(); + bad.id = 2; + bad.allowance = 2.; + input.operations.push(bad); + let mut generator = Generator::new(input).unwrap(); + let failed = loop { + match generator.step() { + Ok(false) => {} + result => break result, + } + }; + assert!(failed.is_err()); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } +} diff --git a/crates/cam/src/hashing.rs b/crates/cam/src/hashing.rs new file mode 100644 index 00000000..1a6f528d --- /dev/null +++ b/crates/cam/src/hashing.rs @@ -0,0 +1,150 @@ +//! Incremental encoding of the existing snapshot fingerprint, without a full JSON buffer. +use crate::{Error, JobInput, Result, VERSION, invalid}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +struct Encoder(Sha256); +impl Encoder { + fn bytes(&mut self, bytes: &[u8]) { + self.0.update(bytes); + } + fn value(&mut self, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(value).map_err(|e| Error(e.to_string()))?; + self.bytes(&bytes); + Ok(()) + } + async fn array>( + &mut self, + values: &[T], + batch: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result<()> { + self.bytes(b"["); + for (index, chunk) in values.chunks(batch).enumerate() { + checkpoint(cancelled, yield_control).await?; + if index != 0 { + self.bytes(b","); + } + // Serialize each bounded slice using Serde's own number/string rules. + // Only its enclosing brackets are omitted when joining array chunks. + let bytes = serde_json::to_vec(chunk).map_err(|e| Error(e.to_string()))?; + let contents = bytes.get(1..bytes.len().saturating_sub(1)).ok_or_else(|| Error("invalid fingerprint array encoding".into()))?; + self.bytes(contents); + } + self.bytes(b"]"); + Ok(()) + } +} +async fn checkpoint>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> { + if cancelled() { + return invalid("fingerprint cancelled"); + } + yield_control().await; + if cancelled() { invalid("fingerprint cancelled") } else { Ok(()) } +} + +/// Hash a validated immutable job using exactly the bytes of `fingerprint(input)`. +/// Yields before each operation/tool and every 256 triangles. Encoding one setup +/// or operation remains atomic; this is not a hard elapsed-time guarantee. +/// Cancellation returns no digest and never mutates the input. +pub async fn fingerprint_job_cooperative>( + input: &JobInput, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + let mut encoder = Encoder(Sha256::new()); + encoder.bytes(b"["); + encoder.value(&VERSION)?; + // Field order deliberately matches the derived JobInput/Geometry encoders. + // Equality regressions below make schema additions or reorderings visible. + encoder.bytes(b",{\"setup\":"); + encoder.value(&input.setup)?; + encoder.bytes(b",\"tools\":"); + encoder.array(&input.tools, 1, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"operations\":"); + encoder.array(&input.operations, 1, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"geometry\":{\"target\":"); + encoder.array(&input.geometry.target, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"fixtures\":"); + encoder.array(&input.geometry.fixtures, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"fixture_groups\":"); + encoder.array(&input.geometry.fixture_groups, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b"},\"fingerprint\":"); + encoder.value(&input.fingerprint)?; + encoder.bytes(b"}]"); + checkpoint(&cancelled, &mut yield_control).await?; + Ok(format!("{:x}", encoder.0.finalize())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::{ + cell::Cell, + future::Future, + task::{Context, Poll, Waker}, + }; + + fn ready(future: impl Future) -> T { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(value) => value, + Poll::Pending => panic!("test checkpoint unexpectedly suspended"), + } + } + #[test] + fn cooperative_encoding_preserves_existing_snapshot_fingerprints() { + for name in crate::corpus::CASES { + let mut input = crate::corpus::fixture(name).unwrap(); + input.setup.name = "Escaped \"setup\" \\ \nμ".into(); + input.fingerprint = "Existing digest remains part of the serialized input".into(); + let expected = crate::fingerprint(&input).unwrap(); + let count = Cell::new(0); + let actual = ready(fingerprint_job_cooperative( + &input, + || false, + || { + count.set(count.get() + 1); + std::future::ready(()) + }, + )) + .unwrap(); + assert_eq!(actual, expected, "{name}"); + assert!(count.get() >= 4); + } + } + #[test] + fn large_snapshot_hashing_yields_and_cancellation_never_returns_a_digest() { + let mut input = crate::corpus::fixture("face").unwrap(); + input.geometry.target.resize(200_000, input.geometry.target[0]); + let expected = crate::fingerprint(&input).unwrap(); + let count = Cell::new(0); + let actual = ready(fingerprint_job_cooperative( + &input, + || false, + || { + count.set(count.get() + 1); + std::future::ready(()) + }, + )) + .unwrap(); + assert_eq!(actual, expected); + assert!(count.get() > 780); + for stop in [0, 1, 400, count.get()] { + let checkpoints = Cell::new(0); + let result = ready(fingerprint_job_cooperative( + &input, + || checkpoints.get() >= stop, + || { + checkpoints.set(checkpoints.get() + 1); + std::future::ready(()) + }, + )); + assert!(result.is_err(), "cancel at checkpoint {stop}"); + } + assert_eq!(crate::fingerprint(&input).unwrap(), expected); + } +} diff --git a/crates/cam/src/lib.rs b/crates/cam/src/lib.rs new file mode 100644 index 00000000..d2052fdf --- /dev/null +++ b/crates/cam/src/lib.rs @@ -0,0 +1,51 @@ +//! Milling definitions, deterministic toolpaths and independent NC verification. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod collision; +pub mod compare; +pub mod corpus; +mod coverage; +mod dimensions; +mod hashing; +pub use hashing::fingerprint_job_cooperative; +pub mod generate; +mod model; +pub mod nc; +pub mod playback; +mod region; +mod spatial; +mod stock_mesh; +mod surface; +pub mod verify; +pub use model::*; + +#[derive(Debug, thiserror::Error)] +#[error("CAM: {0}")] +pub struct Error(pub String); +pub type Result = std::result::Result; +pub(crate) fn invalid(message: impl Into) -> Result { + Err(Error(message.into())) +} + +pub const VERSION: &str = "cam/18"; +pub const TOLERANCE: f64 = 0.01; +pub const POST_ERROR: f64 = 0.001; +pub const VERIFY_ERROR: f64 = 0.0025; +/// Maximum upward height change for closing tiny saddle contacts in stock exports. +/// Verification intervals are unchanged; exported meshes require independent comparison. +pub const STOCK_EXPORT_ERROR: f64 = POST_ERROR / 100.; +pub const MAX_MOVES: usize = 200_000; +pub const MAX_ITEMS: usize = 1000; + +/// Maximum chord error when resolving an associated CAD profile. The independent +/// dimensional envelope reserves this in addition to post/interpolation error. +pub fn profile_tolerance(tolerance: f64) -> f64 { + (tolerance / 16.).min(POST_ERROR / 2.) +} + +pub fn fingerprint(value: &T) -> Result { + use sha2::{Digest, Sha256}; + let data = serde_json::to_vec(&(VERSION, value)).map_err(|e| Error(e.to_string()))?; + Ok(format!("{:x}", Sha256::digest(data))) +} diff --git a/crates/cam/src/model.rs b/crates/cam/src/model.rs new file mode 100644 index 00000000..240aa3a7 --- /dev/null +++ b/crates/cam/src/model.rs @@ -0,0 +1,523 @@ +use crate::{MAX_ITEMS, Result, TOLERANCE, invalid}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manufacturing { + #[serde(default)] + pub setups: Vec, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub operations: Vec, + #[serde(default)] + pub programs: Vec, +} +impl Manufacturing { + pub fn is_empty(&self) -> bool { + self.setups.is_empty() && self.tools.is_empty() && self.operations.is_empty() && self.programs.is_empty() + } + pub fn validate(&self) -> Result<()> { + for ids in [ + self.setups.iter().map(|x| x.id).collect::>(), + self.tools.iter().map(|x| x.id).collect(), + self.operations.iter().map(|x| x.id).collect(), + self.programs.iter().map(|x| x.id).collect(), + ] { + if ids.len() > MAX_ITEMS || ids.contains(&0) || ids.iter().collect::>().len() != ids.len() { + return invalid("too many items or duplicate/zero IDs"); + } + } + for s in &self.setups { + s.validate()?; + } + for t in &self.tools { + t.validate()?; + } + for o in &self.operations { + let s = self.setups.iter().find(|s| s.id == o.setup).ok_or_else(|| crate::Error("missing setup".into()))?; + let t = self.tools.iter().find(|t| t.id == o.tool).ok_or_else(|| crate::Error("missing tool".into()))?; + o.validate(s, t)?; + } + for p in &self.programs { + name(&p.name)?; + if p.operations.is_empty() + || p.operations.len() > MAX_ITEMS + || p.operations.iter().collect::>().len() != p.operations.len() + { + return invalid("program needs unique ordered operations"); + } + for id in &p.operations { + if !self.operations.iter().any(|o| o.id == *id && o.setup == p.setup) { + return invalid("program operation is missing or belongs to another setup"); + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BodyRef { + pub body: String, + #[serde(default)] + pub occurrence: Vec, +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Frame { + pub origin: Vec3, + pub x: Vec3, + pub y: Vec3, + pub z: Vec3, +} +impl Default for Frame { + fn default() -> Self { + Self { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y, z: Vec3::Z } + } +} +impl Frame { + pub fn validate(&self) -> Result<()> { + for v in [self.origin, self.x, self.y, self.z] { + point(v)?; + } + if (self.x.dot(self.x) - 1.0).abs() > 1e-8 + || (self.y.dot(self.y) - 1.0).abs() > 1e-8 + || (self.z.dot(self.z) - 1.0).abs() > 1e-8 + || self.x.dot(self.y).abs() > 1e-8 + || self.x.cross(self.y).dist(self.z) > 1e-8 + { + return invalid("setup frame must be right-handed and orthonormal"); + } + Ok(()) + } + pub fn local(&self, p: Vec3) -> Vec3 { + let p = p - self.origin; + Vec3::new(p.dot(self.x), p.dot(self.y), p.dot(self.z)) + } + pub fn world(&self, p: Vec3) -> Vec3 { + self.origin + self.x * p.x + self.y * p.y + self.z * p.z + } +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Stock { + pub min: Vec3, + pub max: Vec3, +} +impl Stock { + pub fn validate(&self) -> Result<()> { + point(self.min)?; + point(self.max)?; + if self.min.x >= self.max.x || self.min.y >= self.max.y || self.min.z >= self.max.z { + return invalid("stock bounds must have positive size"); + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Setup { + pub id: u64, + pub name: String, + pub stock: Stock, + #[serde(default)] + pub frame: Frame, + pub models: Vec, + #[serde(default)] + pub fixtures: Vec, + pub clearance: f64, + pub retract: f64, + #[serde(default = "work_offset")] + pub work_offset: u8, +} +fn work_offset() -> u8 { + 54 +} +impl Setup { + pub fn validate(&self) -> Result<()> { + name(&self.name)?; + self.stock.validate()?; + self.frame.validate()?; + if self.models.is_empty() || self.models.len() + self.fixtures.len() > MAX_ITEMS { + return invalid("setup needs model references (at most 1000)"); + } + for r in self.models.iter().chain(&self.fixtures) { + name(&r.body)?; + if r.occurrence.len() > 64 { + return invalid("occurrence path too deep"); + } + } + if !finite(self.clearance) + || !finite(self.retract) + || self.clearance < self.retract + || self.retract <= self.stock.max.z + || !(54..=59).contains(&self.work_offset) + { + return invalid("clearance >= retract > stock top; work offset must be G54–G59"); + } + Ok(()) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolKind { + FlatEndMill, + TwistDrill, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tool { + pub id: u64, + pub name: String, + pub number: u32, + pub kind: ToolKind, + pub diameter: f64, + pub cutting_length: f64, + pub shank_diameter: f64, + pub overall_length: f64, + pub holder_diameter: f64, + pub holder_length: f64, + #[serde(default = "drill_angle")] + pub tip_angle: f64, +} +fn drill_angle() -> f64 { + 118.0_f64.to_radians() +} +impl Tool { + pub fn validate(&self) -> Result<()> { + name(&self.name)?; + if self.number == 0 + || self.number > 9999 + || [self.diameter, self.cutting_length, self.shank_diameter, self.overall_length, self.holder_diameter, self.holder_length] + .iter() + .any(|v| !positive(*v)) + || self.overall_length < self.cutting_length + || !self.tip_angle.is_finite() + || !(0.1..3.1).contains(&self.tip_angle) + { + return invalid("invalid tool dimensions, number or drill angle (radians)"); + } + Ok(()) + } + pub fn tip_length(&self) -> f64 { + if self.kind == ToolKind::TwistDrill { self.diameter * 0.5 / (self.tip_angle * 0.5).tan() } else { 0.0 } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Boundary { + pub outer: Vec<[f64; 2]>, + #[serde(default)] + pub islands: Vec>, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileRef { + pub sketch: u64, + pub curves: Vec, + #[serde(default)] + pub occurrence: Vec, +} +/// Document-local identities only. The engine resolves these into setup XY centers. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HoleSource { + Face { target: BodyRef, name: String }, + Profile { profile: ProfileRef }, +} +impl HoleSource { + pub fn validate(&self) -> Result<()> { + match self { + Self::Face { target, name: face } => { + name(&target.body)?; + name(face)?; + if target.occurrence.len() > 64 { + return invalid("hole occurrence path too deep"); + } + } + Self::Profile { profile } => { + if profile.sketch == 0 || profile.curves.is_empty() || profile.curves.len() > 10_000 || profile.occurrence.len() > 64 { + return invalid("invalid circular profile reference"); + } + for curve in &profile.curves { + name(curve)?; + } + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)] +pub enum Strategy { + Face, + Contour { + boundary: Boundary, + inside: bool, + }, + Pocket { + boundary: Boundary, + }, + Drill { + holes: Vec<[f64; 2]>, + #[serde(default)] + peck: Option, + #[serde(default)] + full_diameter: bool, + }, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Operation { + pub id: u64, + pub name: String, + pub setup: u64, + pub tool: u64, + pub geometry: Strategy, + #[serde(default)] + pub source: Option, + /// When nonempty, replaces every preview XY center during snapshot preparation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hole_sources: Vec, + pub top: f64, + pub bottom: f64, + pub stepdown: f64, + pub stepover: f64, + pub feed: f64, + pub plunge_feed: f64, + pub spindle: f64, + #[serde(default)] + pub allowance: f64, + #[serde(default = "yes")] + pub climb: bool, + #[serde(default = "tolerance")] + pub tolerance: f64, + #[serde(default)] + pub coolant: bool, + /// Optional entry ramp angle in radians; None uses a center-cutting plunge. + #[serde(default)] + pub ramp_angle: Option, + #[serde(default)] + pub lead_distance: f64, +} +fn yes() -> bool { + true +} +fn tolerance() -> f64 { + TOLERANCE +} +impl Operation { + pub fn validate(&self, s: &Setup, t: &Tool) -> Result<()> { + name(&self.name)?; + if !finite(self.lead_distance) + || self.lead_distance < 0.0 + || self.ramp_angle.is_some_and(|a| !a.is_finite() || !(0.001..=0.174533).contains(&a)) + { + return invalid("lead must be nonnegative; ramp angle must be 0.06–10 degrees"); + } + if (self.ramp_angle.is_some() || self.lead_distance > 0.0) && matches!(self.geometry, Strategy::Face | Strategy::Drill { .. }) { + return invalid("lead/ramp entry is supported for contour and pocket operations"); + } + if let Some(source) = &self.source { + if source.sketch == 0 + || source.curves.is_empty() + || source.curves.len() > 10_000 + || source.occurrence.len() > 64 + || !matches!(self.geometry, Strategy::Contour { .. } | Strategy::Pocket { .. }) + { + return invalid("profile association requires a bounded contour/pocket reference"); + } + for c in &source.curves { + name(c)?; + } + } + if !self.hole_sources.is_empty() { + let Strategy::Drill { holes, .. } = &self.geometry else { + return invalid("hole references require drilling"); + }; + if self.hole_sources.len() > MAX_ITEMS || self.hole_sources.len() != holes.len() { + return invalid("each linked hole needs exactly one source reference"); + } + for (index, source) in self.hole_sources.iter().enumerate() { + source.validate()?; + if self.hole_sources.iter().take(index).any(|old| old == source) { + return invalid("duplicate hole reference"); + } + } + } + if [self.stepdown, self.stepover, self.feed, self.plunge_feed, self.spindle, self.tolerance].iter().any(|v| !positive(*v)) + || !finite(self.top) + || !finite(self.bottom) + || !finite(self.allowance) + || self.allowance < 0.0 + || self.bottom >= self.top + || self.top > s.stock.max.z + || self.top < s.stock.min.z + || self.stepover > t.diameter + || self.top - self.bottom + t.tip_length() > t.cutting_length + || (self.top - self.bottom) / self.stepdown > 10_000.0 + || self.tolerance < 1e-5 + || self.tolerance > 1.0 + { + return invalid("invalid heights, feeds, step sizes, tolerance or cutting reach"); + } + match &self.geometry { + Strategy::Drill { holes, peck, .. } => { + if t.kind != ToolKind::TwistDrill + || self.allowance != 0.0 + || holes.is_empty() + || holes.len() > MAX_ITEMS + || peck.is_some_and(|v| !positive(v) || (self.top - self.bottom) / v > 10_000.0) + { + return invalid( + "drilling needs a drill, bounded holes, positive peck and zero stock allowance; set the required depth explicitly", + ); + } + for p in holes { + point(Vec3::new(p[0], p[1], 0.0))?; + } + } + Strategy::Contour { boundary, .. } | Strategy::Pocket { boundary } => { + if t.kind != ToolKind::FlatEndMill { + return invalid("milling needs a flat end mill"); + } + boundary.validate()?; + } + Strategy::Face => { + if t.kind != ToolKind::FlatEndMill { + return invalid("facing needs a flat end mill"); + } + } + } + Ok(()) + } +} +impl Boundary { + pub fn validate(&self) -> Result<()> { + if self.islands.len() > 100 { + return invalid("too many islands"); + } + let mut total = 0; + for ring in std::iter::once(&self.outer).chain(&self.islands) { + total += ring.len(); + if ring.len() < 3 || total > 10_000 { + return invalid("boundary needs 3–10000 vertices"); + } + for p in ring { + point(Vec3::new(p[0], p[1], 0.0))?; + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Program { + pub id: u64, + pub name: String, + pub setup: u64, + pub operations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Geometry { + pub target: Vec<[Vec3; 3]>, + pub fixtures: Vec<[Vec3; 3]>, + /// Triangle counts for consecutive fixture bodies. Shells of one body share + /// a winding sum so enclosed cavities remain empty. Legacy snapshots contain + /// one triangle soup; an empty list treats that soup as one body. + #[serde(default)] + pub fixture_groups: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JobInput { + pub setup: Setup, + pub tools: Vec, + pub operations: Vec, + pub geometry: Geometry, + pub fingerprint: String, +} +impl JobInput { + pub fn validate(&self) -> Result<()> { + let manufacturing = Manufacturing { + setups: vec![self.setup.clone()], + tools: self.tools.clone(), + operations: self.operations.clone(), + programs: vec![Program { + id: 1, + name: "Snapshot".into(), + setup: self.setup.id, + operations: self.operations.iter().map(|o| o.id).collect(), + }], + }; + manufacturing.validate()?; + if self.tools.iter().map(|t| t.number).collect::>().len() != self.tools.len() { + return invalid("ambiguous tool numbers"); + } + if self.geometry.target.is_empty() || self.geometry.target.len() + self.geometry.fixtures.len() > 200_000 { + return invalid("empty or excessive verification geometry"); + } + if !self.geometry.fixture_groups.is_empty() + && (self.geometry.fixture_groups.len() > MAX_ITEMS + || self.geometry.fixture_groups.contains(&0) + || self.geometry.fixture_groups.iter().try_fold(0usize, |sum, n| sum.checked_add(*n)) != Some(self.geometry.fixtures.len())) + { + return invalid("invalid fixture body triangle groups"); + } + for triangle in self.geometry.target.iter().chain(&self.geometry.fixtures) { + for p in triangle { + point(*p)?; + } + } + for p in self.geometry.target.iter().flatten() { + let a = self.setup.stock.min; + let b = self.setup.stock.max; + if p.x < a.x - TOLERANCE + || p.y < a.y - TOLERANCE + || p.z < a.z - TOLERANCE + || p.x > b.x + TOLERANCE + || p.y > b.y + TOLERANCE + || p.z > b.z + TOLERANCE + { + return invalid("rectangular stock must enclose selected model geometry"); + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Motion { + Rapid { to: Vec3 }, + Linear { to: Vec3, feed: f64 }, + Arc { to: Vec3, center: Vec3, clockwise: bool, feed: f64 }, + Tool { tool: u64, number: u32 }, + Spindle { rpm: f64 }, + Coolant { on: bool }, + Stop, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Block { + pub operation: u64, + pub motion: Motion, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Toolpath { + pub fingerprint: String, + pub blocks: Vec, +} +pub(crate) fn finite(v: f64) -> bool { + v.is_finite() && v.abs() <= 1e6 +} +pub(crate) fn positive(v: f64) -> bool { + finite(v) && v > 0.0 +} +pub(crate) fn point(v: Vec3) -> Result<()> { + if [v.x, v.y, v.z].into_iter().all(finite) { Ok(()) } else { invalid("non-finite or excessive coordinate") } +} +fn name(s: &str) -> Result<()> { + if s.trim().is_empty() || s.len() > 256 || s.chars().any(char::is_control) { invalid("invalid name") } else { Ok(()) } +} diff --git a/crates/cam/src/nc.rs b/crates/cam/src/nc.rs new file mode 100644 index 00000000..0b58a0ea --- /dev/null +++ b/crates/cam/src/nc.rs @@ -0,0 +1,712 @@ +//! Strict reference-dialect post and independent modal NC reader. +//! The reader has no dependency on the toolpath generator. +use crate::*; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; +use std::{ + future::Future, + task::{Context, Poll, Waker}, +}; + +pub const DIALECT: &str = "solidcraft-reference-3axis/2; not a qualified machine post"; +// A maximum-size path also emits the three explicit startup events below. +const MAX_RECORDS: usize = MAX_MOVES + 3; +fn complete(future: impl Future>) -> Result { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result, + Poll::Pending => invalid("synchronous NC checkpoint unexpectedly suspended"), + } +} +async fn checkpoint>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> { + if cancelled() { + return invalid("NC processing cancelled"); + } + yield_control().await; + if cancelled() { invalid("NC processing cancelled") } else { Ok(()) } +} +fn startup(setup: &Setup) -> [Motion; 3] { + [Motion::Spindle { rpm: 0.0 }, Motion::Coolant { on: false }, Motion::Rapid { to: Vec3::new(0., 0., setup.clearance) }] +} +pub fn post(path: &Toolpath, setup: &Setup) -> Result { + complete(post_cooperative(path, setup, || false, || std::future::ready(()))) +} +/// Preserve post bytes while yielding every 256 blocks. No partial output on cancellation. +pub async fn post_cooperative>( + path: &Toolpath, + setup: &Setup, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + if path.blocks.is_empty() || path.blocks.len() > MAX_MOVES { + return invalid("cannot post an empty or excessive path"); + } + let mut out = format!("({DIALECT})\n(Fingerprint {})\nG21 G90 G17 G91.1 G94 G{} G40 G49 G80\nM5\nM9\n", path.fingerprint, setup.work_offset); + let mut current = Vec3::new(0.0, 0.0, setup.clearance); + out.push_str(&format!("G0 X0.000000 Y0.000000 Z{:.6}\n", setup.clearance)); + for (index, block) in path.blocks.iter().enumerate() { + if index.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + out.push_str(&format!("(Operation {})\n", block.operation)); + let line = match &block.motion { + Motion::Rapid { to } => format!("G0 X{:.6} Y{:.6} Z{:.6}", to.x, to.y, to.z), + Motion::Linear { to, feed } => format!("G1 X{:.6} Y{:.6} Z{:.6} F{:.6}", to.x, to.y, to.z, feed), + Motion::Arc { to, center, clockwise, feed } => format!( + "G{} X{:.6} Y{:.6} Z{:.6} I{:.6} J{:.6} F{:.6}", + if *clockwise { 2 } else { 3 }, + to.x, + to.y, + to.z, + center.x - current.x, + center.y - current.y, + feed + ), + Motion::Tool { number, .. } => format!("T{number} M6"), + Motion::Spindle { rpm } => { + if *rpm == 0.0 { + "M5".into() + } else { + format!("S{rpm:.6} M3") + } + } + Motion::Coolant { on } => { + if *on { + "M8".into() + } else { + "M9".into() + } + } + Motion::Stop => "M0".into(), + }; + out.push_str(&line); + out.push('\n'); + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &block.motion { + current = *to; + } + } + out.push_str("M30\n"); + checkpoint(&cancelled, &mut yield_control).await?; + Ok(out) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Record { + pub line: usize, + pub motion: Motion, +} +/// Provenance established only after the independent reader matches a posted block. +/// `block` is one-based and also the completed-block count used by playback. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: usize, + pub block: usize, + pub operation: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Parsed { + pub records: Vec, + pub work_offset: u8, + // Never accept provenance from comments or deserialized external programs. + #[serde(skip)] + sources: Vec, +} +impl Parsed { + pub fn source_for_line(&self, line: usize) -> Option { + self.sources.binary_search_by_key(&line, |s| s.line).ok().and_then(|i| self.sources.get(i).copied()) + } + /// First interpreted record on a physical NC line, using a zero-based index. + pub fn record_for_line(&self, line: usize) -> Option { + let i = self.records.partition_point(|r| r.line < line); + self.records.get(i).filter(|r| r.line == line).map(|_| i) + } +} + +/// Strictly consumes every word. Units and distance mode must be declared before motion. +pub fn parse(source: &str, tools: &[Tool]) -> Result { + complete(parse_cooperative(source, tools, || false, || std::future::ready(()))) +} +/// Independent modal reader with cancellation and a checkpoint every 256 physical lines. +pub async fn parse_cooperative>( + source: &str, + tools: &[Tool], + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + if source.len() > 32 * 1024 * 1024 { + return invalid("NC file too large"); + } + let mut out = Vec::new(); + let mut units = None; + let mut absolute = None; + let mut plane = false; + let mut per_minute = false; + let mut offset = None; + let mut pos = Vec3::ZERO; + let mut have_pos = false; + let mut mode = None; + let mut feed = 0.0; + let mut rpm = 0.0; + let mut spindle_on = false; + let mut coolant_on = false; + let mut selected = None; + let mut ended = false; + for (index, line) in source.lines().enumerate() { + if index.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + let line_number = index + 1; + let fail = |s: &str| Error(format!("NC line {line_number}: {s}")); + let mut words = words(line).map_err(|e| fail(&e.0))?; + // Modal changes apply to the whole block, independent of word order. + words.sort_by_key(|(letter, _)| if *letter == 'G' { 0 } else { 1 }); + if words.is_empty() { + continue; + } + if ended { + return Err(fail("content after program end")); + } + let mut coordinates = [None; 3]; + let mut ij = [None; 2]; + let mut m = None; + let mut groups = std::collections::BTreeSet::new(); + let mut seen = std::collections::BTreeSet::new(); + for (letter, value) in words { + if letter != 'G' && !seen.insert(letter) { + return Err(fail("duplicate address")); + } + match letter { + 'N' => { + if value.fract() != 0.0 || value < 0.0 { + return Err(fail("invalid line number")); + } + } + 'G' => { + let group = match value { + 0.0..=3.0 if value.fract() == 0.0 => { + mode = Some(value as u8); + 0 + } + 17.0 => { + plane = true; + 1 + } + 20.0 => { + units = Some(25.4); + 2 + } + 21.0 => { + units = Some(1.0); + 2 + } + 90.0 => { + absolute = Some(true); + 3 + } + 91.0 => { + absolute = Some(false); + 3 + } + 94.0 => { + per_minute = true; + 4 + } + 54.0..=59.0 if value.fract() == 0.0 => { + if offset.is_some_and(|o| o != value as u8) { + return Err(fail("multiple work offsets unsupported")); + } + offset = Some(value as u8); + 5 + } + 40.0 => 6, + 49.0 => 7, + 80.0 => { + mode = None; + 0 + } + 91.1 => 9, + _ => return Err(fail("unsupported G code; no commands are silently discarded")), + }; + if !groups.insert(group) { + return Err(fail("conflicting modal codes")); + } + } + 'M' => { + if value.fract() != 0.0 { + return Err(fail("invalid M code")); + } + m = Some(value as i32); + } + 'X' => coordinates[0] = Some(value), + 'Y' => coordinates[1] = Some(value), + 'Z' => coordinates[2] = Some(value), + 'I' => ij[0] = Some(value), + 'J' => ij[1] = Some(value), + 'F' => { + let scale = units.ok_or_else(|| fail("declare units before feed"))?; + feed = value * scale; + if !crate::model::positive(feed) { + return Err(fail("feed must be positive")); + } + } + 'S' => { + if value < 0.0 { + return Err(fail("negative spindle speed")); + } + rpm = value; + } + 'T' => { + if value.fract() != 0.0 || value < 1.0 || value > 9999.0 { + return Err(fail("invalid tool number")); + } + selected = Some(value as u32); + } + _ => return Err(fail("unsupported address")), + } + } + let moving = coordinates.iter().any(Option::is_some) || ij.iter().any(Option::is_some); + if moving && m.is_some() { + return Err(fail("combined motion/M blocks unsupported by reference dialect")); + } + // Speed changes precede motion and coolant/stop commands in a block. + // Other spindle-changing M codes below already emit its final state. + if seen.contains(&'S') && spindle_on && !matches!(m, Some(3 | 5 | 6 | 2 | 30)) { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm } }); + } + if moving { + let scale = units.ok_or_else(|| fail("missing units"))?; + let abs = absolute.ok_or_else(|| fail("missing distance mode"))?; + if !plane || !per_minute || offset.is_none() { + return Err(fail("declare G17, G94 and work offset before motion")); + } + if !have_pos && (!abs || coordinates.iter().any(Option::is_none)) { + return Err(fail("first position requires absolute XYZ")); + } + let base = [pos.x, pos.y, pos.z]; + let mut xyz = base; + for k in 0..3 { + if let Some(v) = coordinates[k] { + xyz[k] = v * scale + if abs { 0.0 } else { base[k] }; + } + } + let to = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(to)?; + let motion = match mode.ok_or_else(|| fail("missing motion mode"))? { + 0 => { + if ij.iter().any(Option::is_some) { + return Err(fail("arc center on rapid")); + } + Motion::Rapid { to } + } + 1 => { + if ij.iter().any(Option::is_some) || feed <= 0.0 { + return Err(fail("invalid linear feed/center")); + } + Motion::Linear { to, feed } + } + arc => { + if !have_pos || feed <= 0.0 || ij.iter().all(Option::is_none) || (to.z - pos.z).abs() > 1e-8 { + return Err(fail("only planar XY I/J arcs with positive feed are supported")); + } + let center = Vec3::new(pos.x + ij[0].unwrap_or(0.0) * scale, pos.y + ij[1].unwrap_or(0.0) * scale, pos.z); + let r = pos.dist(center); + if r <= 1e-8 || (to.dist(center) - r).abs() > POST_ERROR { + return Err(fail("inconsistent arc radius")); + } + Motion::Arc { to, center, clockwise: arc == 2, feed } + } + }; + out.push(Record { line: line_number, motion }); + pos = to; + have_pos = true; + } + if let Some(m) = m { + let motion = match m { + 0 => Some(Motion::Stop), + 3 => { + if rpm <= 0.0 { + return Err(fail("spindle start requires positive speed")); + } + spindle_on = true; + Some(Motion::Spindle { rpm }) + } + 5 => { + spindle_on = false; + Some(Motion::Spindle { rpm: 0.0 }) + } + 6 => { + let n = selected.ok_or_else(|| fail("tool change needs T"))?; + let matches: Vec<_> = tools.iter().filter(|t| t.number == n).collect(); + if matches.len() != 1 { + return Err(fail("unknown or ambiguous tool number")); + } + // NIST RS274/NGC §3.6.3: M6 stops the spindle without + // restarting it; programmed speed and coolant are retained. + if spindle_on { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } }); + } + spindle_on = false; + Some(Motion::Tool { tool: matches[0].id, number: n }) + } + 8 => { + coolant_on = true; + Some(Motion::Coolant { on: true }) + } + 9 => { + coolant_on = false; + Some(Motion::Coolant { on: false }) + } + 2 | 30 => { + // Program end stops spindle and coolant in the interpreted state. + if spindle_on { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } }); + } + if coolant_on { + out.push(Record { line: line_number, motion: Motion::Coolant { on: false } }); + } + ended = true; + None + } + _ => return Err(fail("unsupported M code")), + }; + if let Some(motion) = motion { + out.push(Record { line: line_number, motion }); + } + } + if out.len() > MAX_RECORDS { + return Err(fail("NC motion limit")); + } + } + if !ended || !have_pos { + return invalid("NC program requires motion and an explicit program end"); + } + checkpoint(&cancelled, &mut yield_control).await?; + Ok(Parsed { records: out, work_offset: offset.ok_or_else(|| Error("missing work offset".into()))?, sources: Vec::new() }) +} +fn words(line: &str) -> Result> { + if line.len() > 4096 { + return invalid("NC line too long"); + } + let mut clean = String::new(); + let mut comment = false; + for c in line.chars() { + match c { + '(' if !comment => comment = true, + ')' if comment => comment = false, + '(' | ')' => return invalid("malformed comment"), + ';' if !comment => break, + _ if !comment => clean.push(c), + _ => {} + } + } + if comment { + return invalid("unterminated comment"); + } + let clean = clean.trim(); + if clean == "%" { + return Ok(Vec::new()); + } + let chars: Vec<_> = clean.chars().collect(); + let mut i = 0; + let mut result = Vec::new(); + while i < chars.len() { + if chars[i].is_whitespace() { + i += 1; + continue; + } + let letter = chars[i].to_ascii_uppercase(); + if !letter.is_ascii_uppercase() { + return invalid("expected address letter"); + } + i += 1; + while chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + } + let start = i; + while chars.get(i).is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + let v = s.parse::().map_err(|_| Error("invalid numeric word".into()))?; + if !crate::model::finite(v) { + return invalid("non-finite/excessive word"); + } + result.push((letter, v)); + } + Ok(result) +} + +pub fn round_trip(path: &Toolpath, setup: &Setup, tools: &[Tool]) -> Result { + complete(round_trip_cooperative(path, setup, tools, || false, || std::future::ready(()))) +} +/// Post, independently interpret and compare in bounded batches, retaining trusted line mappings. +pub async fn round_trip_cooperative>( + path: &Toolpath, + setup: &Setup, + tools: &[Tool], + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + let source = post_cooperative(path, setup, &cancelled, &mut yield_control).await?; + let mut parsed = parse_cooperative(&source, tools, &cancelled, &mut yield_control).await?; + let startup = startup(setup); + if parsed.work_offset != setup.work_offset || parsed.records.len() != path.blocks.len() + startup.len() { + return invalid("post changed block count or work offset"); + } + if !startup.iter().zip(&parsed.records).all(|(a, b)| equivalent(a, &b.motion)) { + return invalid("post changed startup state"); + } + let mut original_position = Vec3::new(0., 0., setup.clearance); + let mut posted_position = original_position; + if let Some(Record { motion: Motion::Rapid { to }, .. }) = parsed.records.get(startup.len() - 1) { + posted_position = *to; + } + for (i, (a, b)) in path.blocks.iter().zip(parsed.records.iter().skip(startup.len())).enumerate() { + if i.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + if !equivalent(&a.motion, &b.motion) { + return invalid(format!("post mismatch at NC line {}", b.line)); + } + if let (Motion::Arc { to, center, clockwise, .. }, Motion::Arc { to: posted_to, center: posted_center, .. }) = (&a.motion, &b.motion) { + let before = sweep(original_position, *to, *center, *clockwise); + let after = sweep(posted_position, *posted_to, *posted_center, *clockwise); + if (before - after).abs() * original_position.dist(*center) > POST_ERROR { + return invalid(format!("post changed arc sweep at NC line {}", b.line)); + } + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = a.motion { + original_position = to; + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = b.motion { + posted_position = to; + } + parsed.sources.push(SourceLocation { line: b.line, block: i + 1, operation: a.operation }); + } + checkpoint(&cancelled, &mut yield_control).await?; + Ok(parsed) +} +fn sweep(from: Vec3, to: Vec3, center: Vec3, clockwise: bool) -> f64 { + if from.x == to.x && from.y == to.y { + return if clockwise { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + } + let a = (from.y - center.y).atan2(from.x - center.x); + let b = (to.y - center.y).atan2(to.x - center.x); + if clockwise { -(a - b).rem_euclid(std::f64::consts::TAU) } else { (b - a).rem_euclid(std::f64::consts::TAU) } +} +fn equivalent(a: &Motion, b: &Motion) -> bool { + match (a, b) { + (Motion::Rapid { to: a }, Motion::Rapid { to: b }) => a.dist(*b) <= POST_ERROR, + (Motion::Linear { to: a, feed: f }, Motion::Linear { to: b, feed: g }) => a.dist(*b) <= POST_ERROR && (f - g).abs() <= 1e-6, + (Motion::Arc { to: a, center: c, clockwise: d, feed: f }, Motion::Arc { to: b, center: e, clockwise: h, feed: g }) => { + a.dist(*b) <= POST_ERROR && c.dist(*e) <= POST_ERROR && d == h && (f - g).abs() <= 1e-6 + } + (Motion::Spindle { rpm: a }, Motion::Spindle { rpm: b }) => (a - b).abs() <= 1e-6, + _ => a == b, + } +} + +#[derive(Clone, Debug)] +pub struct Segment { + pub from: Vec3, + pub to: Vec3, + pub rapid: bool, + pub feed: f64, + pub tool: u64, + pub line: usize, +} +/// Expand interpreted circular motion with a bounded chord error, independent of posting. +pub fn segments(parsed: &Parsed, setup: &Setup) -> Result> { + let mut out = Vec::new(); + let mut pos = None; + let mut tool = 0; + let mut spindle = 0.0; + for r in &parsed.records { + match &r.motion { + Motion::Tool { tool: id, .. } => { + if pos.is_none_or(|p: Vec3| p.z < setup.clearance - POST_ERROR) { + return invalid(format!("tool change below clearance at line {}", r.line)); + } + if spindle != 0.0 { + return invalid("tool change with spindle running"); + } + tool = *id; + } + Motion::Spindle { rpm } => spindle = *rpm, + Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } => { + let rapid = matches!(r.motion, Motion::Rapid { .. }); + let feed = match r.motion { + Motion::Linear { feed, .. } | Motion::Arc { feed, .. } => feed, + _ => 0.0, + }; + if !rapid && (tool == 0 || spindle <= 0.0) { + return invalid(format!("cutting without tool/spindle at line {}", r.line)); + } + if let Some(from) = pos { + if tool == 0 { + return invalid(format!("motion before selecting a known tool at line {}", r.line)); + } + if let Motion::Arc { center, clockwise, .. } = r.motion { + let a: f64 = (from.y - center.y).atan2(from.x - center.x); + let sweep = sweep(from, *to, center, clockwise); + let radius = from.dist(center); + let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6); + let count = (sweep.abs() / step).ceil() as usize; + if count > MAX_MOVES || out.len() + count > MAX_MOVES { + return invalid("arc subdivision limit"); + } + let mut prev = from; + for k in 1..=count { + let angle = a + sweep * k as f64 / count as f64; + let p = if k == count { *to } else { Vec3::new(center.x + radius * angle.cos(), center.y + radius * angle.sin(), to.z) }; + out.push(Segment { from: prev, to: p, rapid, feed, tool, line: r.line }); + prev = p; + } + } else { + out.push(Segment { from, to: *to, rapid, feed, tool, line: r.line }); + } + } else if !rapid || to.z < setup.clearance - POST_ERROR { + return invalid("initial position must be at clearance"); + } + pos = Some(*to); + } + _ => {} + } + if out.len() > MAX_MOVES { + return invalid("simulation segment limit"); + } + } + Ok(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod provenance_tests { + use super::*; + #[test] + fn tiny_arcs_cannot_round_into_unintended_full_circles() { + let input = corpus::fixture("face").unwrap(); + for clockwise in [false, true] { + let to = Vec3::new(1., if clockwise { -1e-8 } else { 1e-8 }, 15.); + let path = Toolpath { + fingerprint: input.fingerprint.clone(), + blocks: vec![ + Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 1000. } }, + Block { operation: 1, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } }, + Block { operation: 1, motion: Motion::Arc { to, center: Vec3::new(0., 0., 15.), clockwise, feed: 100. } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 0. } }, + ], + }; + assert!(round_trip(&path, &input.setup, &input.tools).unwrap_err().0.contains("arc sweep")); + } + let raw = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X1 Y0.0000000000001 I-1 J0 F100\nM5\nM30"; + let parsed = parse(raw, &input.tools).unwrap(); + let distance: f64 = segments(&parsed, &input.setup).unwrap().iter().map(|s| s.from.dist(s.to)).sum(); + assert!(distance < 1e-10, "a noncoincident tiny arc is not a full circle: {distance}"); + } + #[test] + fn diagnostic_sources_follow_checked_records_and_ignore_untrusted_annotations() { + let input = corpus::fixture("face").unwrap(); + let path = Toolpath { + fingerprint: input.fingerprint.clone(), + blocks: vec![ + Block { operation: 10, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } }, + Block { operation: 10, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } }, + Block { + operation: 20, + motion: Motion::Arc { to: Vec3::new(1., 0., 15.), center: Vec3::new(0., 0., 15.), clockwise: true, feed: 100. }, + }, + Block { operation: 20, motion: Motion::Spindle { rpm: 0. } }, + Block { operation: 20, motion: Motion::Stop }, + ], + }; + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let prefix = startup(&input.setup).len(); + for (i, record) in parsed.records.iter().skip(prefix).enumerate() { + assert_eq!( + parsed.source_for_line(record.line), + Some(SourceLocation { line: record.line, block: i + 1, operation: path.blocks[i].operation }) + ); + assert_eq!(parsed.record_for_line(record.line), Some(i + prefix)); + } + for record in parsed.records.iter().take(prefix) { + assert!(parsed.source_for_line(record.line).is_none()); + } + assert!(parsed.source_for_line(7).is_none()); // comment + let arc = &parsed.records[prefix + 3]; + let segments = segments(&parsed, &input.setup).unwrap(); + assert!(segments.iter().filter(|s| s.line == arc.line).count() > 10); + assert!(segments.iter().filter(|s| s.line == arc.line).all(|s| parsed.source_for_line(s.line).unwrap().block == 4)); + let raw = parse(&post(&path, &input.setup).unwrap().replace("Operation 20", "Operation 999"), &input.tools).unwrap(); + assert!(raw.source_for_line(arc.line).is_none()); + let mut serialized = serde_json::to_value(&parsed).unwrap(); + serialized["sources"] = serde_json::json!([{"line":arc.line,"block":4,"operation":999}]); + let decoded: Parsed = serde_json::from_value(serialized).unwrap(); + assert!(decoded.source_for_line(arc.line).is_none()); + } + #[test] + fn diagnostic_fault_findings_retain_checked_block_but_raw_nc_has_only_record() { + let input = corpus::fixture("face").unwrap(); + let mut path = generate::generate(input.clone()).unwrap(); + for block in &mut path.blocks { + if let Motion::Linear { to, .. } = &mut block.motion + && to.z == 5. + { + to.z = 4.; + } + } + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + let finding = report.findings.iter().find(|f| f.kind == "gouge").unwrap(); + assert_eq!(finding.severity, verify::FindingSeverity::Error); + assert_eq!(report.findings[0].severity, verify::FindingSeverity::Error); + let source = finding.source.unwrap(); + assert_eq!(Some(source.line), finding.line); + assert_eq!(source.operation, path.blocks[source.block - 1].operation); + assert_eq!(finding.record, Some(source.block + startup(&input.setup).len())); + let raw = parse(&post(&path, &input.setup).unwrap(), &input.tools).unwrap(); + let raw_report = verify::simulate(&input, &raw, Default::default()).unwrap(); + assert_eq!(raw_report.status, report.status); + assert_eq!(raw_report.finished_dimensions, report.finished_dimensions); + assert!(raw_report.findings.iter().filter(|f| f.line.is_some()).all(|f| f.source.is_none() && f.record.is_some())); + let legacy: verify::Finding = + serde_json::from_value(serde_json::json!({"kind":"legacy","line":4,"at":[0,0,0],"detail":"old report"})).unwrap(); + assert!(legacy.record.is_none() && legacy.source.is_none()); + } + #[test] + fn diagnostic_retention_keeps_errors_and_budget_reasons_bounded() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let mut report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + report.findings.clear(); + let finding = |line, severity, kind: &str| verify::Finding { + line, + severity, + kind: kind.into(), + record: None, + source: None, + at: Vec3::ZERO, + detail: "witness".into(), + }; + for line in 1..=250 { + report.finding(finding(Some(line), verify::FindingSeverity::Unresolved, "clearance_unresolved")); + } + assert_eq!(report.findings.len(), 200); + report.finding(finding(None, verify::FindingSeverity::Unresolved, "budget_exhausted")); + report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge")); + for _ in 0..250 { + report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge")); + } + assert_eq!(report.findings.len(), 200); + assert_eq!(report.findings.iter().filter(|f| f.kind == "gouge").count(), 1); + assert!(report.findings.iter().any(|f| f.kind == "budget_exhausted")); + // Diagnostic storage is not an input to acceptance decisions. + assert_eq!(report.status, verify::Status::Pass); + } +} diff --git a/crates/cam/src/playback.rs b/crates/cam/src/playback.rs new file mode 100644 index 00000000..e30dea41 --- /dev/null +++ b/crates/cam/src/playback.rs @@ -0,0 +1,73 @@ +//! Machine state at a typed toolpath boundary, shared by inspection and playback. +use crate::*; +use solidcraft_geom::Vec3; +#[derive(Clone, Debug, serde::Serialize)] +pub struct State { + pub completed: usize, + pub position: Vec3, + pub operation: Option, + pub tool: Option, + pub spindle: f64, + pub coolant: bool, + pub feed: Option, +} +pub fn at(path: &Toolpath, setup: &Setup, completed: usize) -> Result { + if completed > path.blocks.len() { + return invalid("playback motion exceeds toolpath length"); + } + let mut state = + State { completed, position: Vec3::new(0., 0., setup.clearance), operation: None, tool: None, spindle: 0., coolant: false, feed: None }; + for block in path.blocks.iter().take(completed) { + state.operation = Some(block.operation); + match block.motion { + Motion::Rapid { to } => state.position = to, + Motion::Linear { to, feed } | Motion::Arc { to, feed, .. } => { + state.position = to; + state.feed = Some(feed); + } + Motion::Tool { tool, .. } => state.tool = Some(tool), + Motion::Spindle { rpm } => state.spindle = rpm, + Motion::Coolant { on } => state.coolant = on, + Motion::Stop => { + state.spindle = 0.; + state.coolant = false; + } + } + } + Ok(state) +} +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn initial_pose_tool_changes_and_shutdown() { + let input = corpus::fixture("face").unwrap(); + let path = Toolpath { + fingerprint: "test".into(), + blocks: vec![ + Block { operation: 10, motion: Motion::Tool { tool: 3, number: 7 } }, + Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } }, + Block { operation: 10, motion: Motion::Coolant { on: true } }, + Block { operation: 10, motion: Motion::Linear { to: Vec3::new(2., 3., 4.), feed: 100. } }, + Block { operation: 11, motion: Motion::Tool { tool: 4, number: 8 } }, + Block { operation: 11, motion: Motion::Stop }, + ], + }; + let start = at(&path, &input.setup, 0).unwrap(); + assert_eq!(start.position, Vec3::new(0., 0., 15.)); + assert!(start.tool.is_none()); + let cut = at(&path, &input.setup, 4).unwrap(); + assert_eq!(cut.position, Vec3::new(2., 3., 4.)); + assert_eq!(cut.tool, Some(3)); + assert_eq!(cut.spindle, 5000.); + assert!(cut.coolant); + assert_eq!(cut.feed, Some(100.)); + let end = at(&path, &input.setup, 6).unwrap(); + assert_eq!(end.tool, Some(4)); + assert_eq!(end.operation, Some(11)); + assert_eq!(end.spindle, 0.); + assert!(!end.coolant); + assert!(at(&path, &input.setup, 7).is_err()); + } +} diff --git a/crates/cam/src/region.rs b/crates/cam/src/region.rs new file mode 100644 index 00000000..f6089dae --- /dev/null +++ b/crates/cam/src/region.rs @@ -0,0 +1,233 @@ +//! Independent planar-region predicates for finished dimensions. No offsets or +//! toolpath-generation routines are used. Winding ignores input ring direction. +use crate::{spatial::Index, *}; + +fn orient(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 { + robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: p[0], y: p[1] }) +} +fn contains(ring: &[[f64; 2]], p: [f64; 2]) -> bool { + ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).fold(false, |inside, (a, b)| inside ^ crossing(*a, *b, p)) +} +fn crossing(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> bool { + (a[1] > p[1]) != (b[1] > p[1]) && if b[1] > a[1] { orient(a, b, p) > 0. } else { orient(a, b, p) < 0. } +} +struct Edge { + a: [f64; 2], + b: [f64; 2], + ring: usize, + vertex: usize, + count: usize, +} +pub(crate) struct Region { + edges: Vec, + index: Index, + max_x: f64, + guard: f64, +} +impl Region { + pub(crate) fn new(boundary: &Boundary) -> Result { + boundary.validate()?; + let rings: Vec<_> = std::iter::once(&boundary.outer).chain(&boundary.islands).collect(); + let mut edges = Vec::new(); + let mut extent = 1_f64; + let mut max_x = f64::NEG_INFINITY; + for (r, ring) in rings.iter().enumerate() { + let mut area = 0.; + let origin = ring[0]; + for (vertex, (&a, &b)) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).enumerate() { + if a.into_iter().any(|v| v != 0. && v.abs() < 1e-100) || (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-7 { + return invalid("unresolvable dimensional boundary edge"); + } + extent = extent.max(a[0].abs()).max(a[1].abs()); + max_x = max_x.max(a[0]); + area += (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]); + edges.push(Edge { a, b, ring: r, vertex, count: ring.len() }); + } + if area.abs() < 1e-8 { + return invalid("zero-area dimensional boundary"); + } + } + let index = Index::new( + edges + .iter() + .enumerate() + .map(|(id, e)| (id, [e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])])) + .collect(), + ); + let mut work = 0; + for (id, e) in edges.iter().enumerate() { + let mut candidates = Vec::new(); + index.query([e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])], &mut candidates); + for other in candidates.into_iter().filter(|other| *other > id) { + work += 1; + if work > 2_000_000 { + return invalid("dimensional boundary validation budget exceeded"); + } + let f = &edges[other]; + if e.ring == f.ring && ((e.vertex + 1) % e.count == f.vertex || (f.vertex + 1) % e.count == e.vertex) { + let (a, b, c) = if e.b == f.a { (e.a, e.b, f.b) } else { (f.a, f.b, e.b) }; + if orient(a, b, c) == 0. && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. { + return invalid("backtracking dimensional boundary"); + } + continue; + } + if (0..2).any(|k| e.a[k].max(e.b[k]) < f.a[k].min(f.b[k]) || f.a[k].max(f.b[k]) < e.a[k].min(e.b[k])) { + continue; + } + let opposite = |x: f64, y: f64| x == 0. || y == 0. || x.is_sign_positive() != y.is_sign_positive(); + if opposite(orient(e.a, e.b, f.a), orient(e.a, e.b, f.b)) && opposite(orient(f.a, f.b, e.a), orient(f.a, f.b, e.b)) { + return invalid("intersecting or touching dimensional boundaries"); + } + } + } + for (id, island) in boundary.islands.iter().enumerate() { + if !contains(&boundary.outer, island[0]) || boundary.islands.iter().enumerate().any(|(j, r)| id != j && contains(r, island[0])) { + return invalid("dimensional islands must be disjoint and inside the outer boundary"); + } + } + Ok(Self { edges, index, max_x, guard: 128. * f64::EPSILON * extent }) + } + /// Signed distance is positive in the material-removal region. Distance to + /// its boundary is 1-Lipschitz, so center distance +/- cell radius bounds the + /// whole cell. A capped nearest-edge query returns an infinite far endpoint. + pub(crate) async fn range>( + &self, + query: ([f64; 2], f64, f64), + work: &mut usize, + budget: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let (p, half, reach) = query; + let guard = self.guard.max(128. * f64::EPSILON * p[0].abs().max(p[1].abs())); + let mut candidates = Vec::new(); + self.index.query(p, [self.max_x.max(p[0]), p[1]], &mut candidates); + let mut inside = false; + for (n, id) in candidates.into_iter().enumerate() { + if n % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let e = &self.edges[id]; + inside ^= crossing(e.a, e.b, p); + } + let radius = reach + half + guard; + let mut candidates = Vec::new(); + self.index.query(p.map(|v| v - radius), p.map(|v| v + radius), &mut candidates); + let mut distance = radius; + for (n, id) in candidates.into_iter().enumerate() { + if n % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let e = &self.edges[id]; + let dx = e.b[0] - e.a[0]; + let dy = e.b[1] - e.a[1]; + let t = (((p[0] - e.a[0]) * dx + (p[1] - e.a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.); + distance = distance.min((p[0] - e.a[0] - t * dx).hypot(p[1] - e.a[1] - t * dy)); + } + let lo = (distance - half - guard).max(0.); + let hi = if distance == radius { f64::INFINITY } else { distance + half + guard }; + if distance <= half + guard { + Ok(Some([-hi, hi])) + } else if inside { + Ok(Some([lo, hi])) + } else { + Ok(Some([-hi, -lo])) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + fn square(a: f64, b: f64) -> Vec<[f64; 2]> { + vec![[a, a], [b, a], [b, b], [a, b]] + } + fn range(r: &Region, p: [f64; 2], half: f64, budget: usize) -> Option<[f64; 2]> { + let mut ready = || std::future::ready(()); + let mut work = 0; + let mut f = std::pin::pin!(r.range((p, half, 10.), &mut work, budget, &|| false, &mut ready)); + match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result.unwrap(), + _ => None, + } + } + #[test] + fn signed_distances_cover_concavity_islands_and_winding() { + let mut boundary = Boundary { outer: square(0., 10.), islands: vec![square(4., 6.)] }; + for _ in 0..2 { + let r = Region::new(&boundary).unwrap(); + for (p, expected) in [([3., 5.], 1.), ([5., 5.], -1.), ([-1., 5.], -1.)] { + let [a, b] = range(&r, p, 0.1, 1000).unwrap(); + assert!((a - (expected - 0.1)).abs() < 1e-10 && (b - (expected + 0.1)).abs() < 1e-10, "{a} {b}"); + } + boundary.outer.reverse(); + boundary.islands[0].reverse(); + } + let r = Region::new(&Boundary { outer: vec![[0., 0.], [4., 0.], [4., 1.], [1., 1.], [1., 4.], [0., 4.]], islands: vec![] }).unwrap(); + assert!((range(&r, [2., 2.], 0., 1000).unwrap()[0] + 1.).abs() < 1e-10); + assert!((range(&r, [0.5, 3.], 0., 1000).unwrap()[0] - 0.5).abs() < 1e-10); + assert!(range(&r, [0.5, 3.], 0., 0).is_none()); + } + #[test] + fn invalid_regions_are_rejected_without_generator_validation() { + for b in [ + Boundary { outer: vec![[0., 0.], [2., 2.], [0., 2.], [2., 0.]], islands: vec![] }, + Boundary { outer: square(0., 10.), islands: vec![square(0., 2.)] }, + Boundary { outer: square(0., 10.), islands: vec![square(2., 8.), square(3., 4.)] }, + Boundary { outer: vec![[0., 0.], [2., 0.], [1., 0.], [2., 2.], [0., 2.]], islands: vec![] }, + ] { + assert!(Region::new(&b).is_err()); + } + } + #[test] + fn large_boundary_queries_yield_and_cancel() { + let b = Boundary { + outer: (0..10_000) + .map(|i| { + let a = (i as f64 + 0.1) * std::f64::consts::TAU / 10_000.; + [a.cos() * 10., a.sin() * 10.] + }) + .collect(), + islands: vec![], + }; + let r = Region::new(&b).unwrap(); + let mut work = 0; + let cancel = std::cell::Cell::new(false); + let cancelled = || cancel.get(); + let mut yielding = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut f = std::pin::pin!(r.range(([0., 0.], 20., 0.), &mut work, 100_000, &cancelled, &mut yielding)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(f.as_mut().poll(&mut cx).is_pending()); + cancel.set(true); + assert!(matches!(f.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + } +} diff --git a/crates/cam/src/spatial.rs b/crates/cam/src/spatial.rs new file mode 100644 index 00000000..3af7c4b7 --- /dev/null +++ b/crates/cam/src/spatial.rs @@ -0,0 +1,65 @@ +// Independent median-split XY index: artifact comparison never calls CAM offsets. +pub(crate) struct Index { + min: [f64; 2], + max: [f64; 2], + items: Vec<(usize, [f64; 2], [f64; 2])>, + children: Vec, +} +impl Index { + pub(crate) fn new(mut items: Vec<(usize, [f64; 2], [f64; 2])>) -> Self { + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for (_, a, b) in &items { + for k in 0..2 { + min[k] = min[k].min(a[k]); + max[k] = max[k].max(b[k]); + } + } + if items.len() <= 8 { + return Self { min, max, items, children: vec![] }; + } + let axis = usize::from(max[1] - min[1] > max[0] - min[0]); + items.sort_unstable_by(|a, b| (a.1[axis] + a.2[axis]).total_cmp(&(b.1[axis] + b.2[axis]))); + let rest = items.split_off(items.len() / 2); + Self { min, max, items: vec![], children: vec![Self::new(items), Self::new(rest)] } + } + pub(crate) fn query(&self, min: [f64; 2], max: [f64; 2], out: &mut Vec) { + if (0..2).any(|k| self.min[k] > max[k] || self.max[k] < min[k]) { + return; + } + out.extend(self.items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| id)); + for child in &self.children { + child.query(min, max, out); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn indexed_candidates_include_every_brute_force_overlap() { + let items: Vec<_> = (0..1024) + .map(|i| { + let x = (i % 32) as f64 - 16.; + let y = (i / 32) as f64 - 16.; + (i, [x, y], [x + 0.5, y + 0.75]) + }) + .collect(); + let index = Index::new(items.clone()); + for (min, max) in [([-0.5, -2.], [3.5, 4.]), ([-16., -16.], [-16., -16.]), ([16., 16.], [17., 17.]), ([-4.75, -7.5], [-4.5, -7.25])] { + let mut actual = Vec::new(); + index.query(min, max, &mut actual); + for (id, a, b) in &items { + if (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k]) { + assert!(actual.contains(id)); + } + } + let expected: Vec<_> = items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| *id).collect(); + actual.sort_unstable(); + assert_eq!(actual, expected); + let unique: std::collections::BTreeSet<_> = actual.iter().collect(); + assert_eq!(unique.len(), actual.len()); + } + } +} diff --git a/crates/cam/src/stock_mesh.rs b/crates/cam/src/stock_mesh.rs new file mode 100644 index 00000000..ce5e6bc1 --- /dev/null +++ b/crates/cam/src/stock_mesh.rs @@ -0,0 +1,728 @@ +//! Conservative boundary extraction from non-overlapping rectangular height cells. +//! Shared planar edges and vertical steps are split before triangulation. No +//! XY snapping, interpolation or generation algorithm is involved. Tiny adaptive +//! saddle contacts may be filled upward within a declared export error budget. +use crate::{ + Error, Result, invalid, + verify::{Cell, Report}, +}; +use solidcraft_geom::Vec3; +use std::{ + collections::{BTreeMap, BTreeSet}, + future::Future, +}; + +/// Count elementary topology work, including sorting and manifold validation. +/// A yield callback which is immediately ready retains the synchronous API. +struct Work { + cancelled: C, + yield_control: Y, + remaining: usize, +} +impl bool, Y: FnMut() -> F, F: Future> Work { + async fn tick(&mut self) -> Result<()> { + if self.remaining == 0 { + if (self.cancelled)() { + return invalid("stock extraction cancelled"); + } + (self.yield_control)().await; + if (self.cancelled)() { + return invalid("stock extraction cancelled"); + } + self.remaining = 256; + } + self.remaining -= 1; + Ok(()) + } + // Sort small runs, then merge with checkpoints. A single standard-library + // sort of up to two million cell events otherwise monopolizes a browser poll. + async fn sort(&mut self, values: &mut Vec, compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Result<()> { + for run in values.chunks_mut(256) { + if run.len() == 256 { + self.remaining = 0; + } + self.tick().await?; + run.sort_unstable_by(&compare); + } + if values.len() <= 256 { + return Ok(()); + } + let mut scratch = Vec::with_capacity(values.len()); + let mut width = 256; + while width < values.len() { + for begin in (0..values.len()).step_by(width * 2) { + let middle = (begin + width).min(values.len()); + let end = (middle + width).min(values.len()); + let (mut a, mut b) = (begin, middle); + while a < middle || b < end { + self.tick().await?; + let take_a = b == end || (a < middle && compare(&values[a], &values[b]).is_le()); + let index = if take_a { + let i = a; + a += 1; + i + } else { + let i = b; + b += 1; + i + }; + scratch.push(values[index].clone()); + } + } + std::mem::swap(values, &mut scratch); + scratch.clear(); + width *= 2; + } + Ok(()) + } +} +fn ready(future: impl Future>) -> Result { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => invalid("synchronous stock extraction unexpectedly yielded"), + } +} + +const MAX_CELLS: usize = 60_000; +const MAX_TRIANGLES: usize = 500_000; +// One percent of the post budget, reserved only for conservative saddle closure. +// This is an artifact approximation; it never changes the verification report. +const MAX_SADDLE_FILL: f64 = crate::STOCK_EXPORT_ERROR; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Coord(f64); +impl Eq for Coord {} +impl PartialOrd for Coord { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Coord { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) } + } +} +type Point = [Coord; 3]; +type XY = [Coord; 2]; +fn point(p: Vec3) -> Point { + [Coord(p.x), Coord(p.y), Coord(p.z)] +} + +#[derive(Clone, Copy)] +struct Event { + at: Coord, + cell: usize, + side: usize, + start: bool, +} +struct Span { + axis: usize, + fixed: f64, + a: f64, + b: f64, + cells: [Option; 2], +} +impl Span { + fn xy(&self, t: f64) -> XY { + if self.axis == 0 { [Coord(self.fixed), Coord(t)] } else { [Coord(t), Coord(self.fixed)] } + } + fn vertex(&self, t: f64, z: f64) -> Vec3 { + let xy = self.xy(t); + Vec3::new(xy[0].0, xy[1].0, z) + } +} + +async fn cells bool, Y: FnMut() -> F, F: Future>(report: &Report, bottom: f64, work: &mut Work) -> Result> { + work.tick().await?; + if !bottom.is_finite() || report.cells.is_empty() || report.cells.len() > 1_000_000 { + return invalid("invalid stock export bounds"); + } + if !report.stock_complete || report.findings.iter().any(|f| f.kind == "budget_exhausted") { + return invalid("stock verification is incomplete; cannot export remaining stock"); + } + for c in &report.cells { + work.tick().await?; + for p in [Vec3::new(c.min[0], c.min[1], bottom), Vec3::new(c.max[0], c.max[1], c.high)] { + crate::model::point(p)?; + } + if c.min[0] >= c.max[0] || c.min[1] >= c.max[1] || !c.low.is_finite() || c.low > c.high || c.high < bottom { + return invalid("invalid stock cell"); + } + } + // Reject overlap in O(n log n). End events precede starts at the same X. + let mut events = Vec::with_capacity(report.cells.len() * 2); + for (i, c) in report.cells.iter().enumerate() { + work.tick().await?; + events.push((Coord(c.min[0]), true, i)); + events.push((Coord(c.max[0]), false, i)); + } + work.sort(&mut events, Ord::cmp).await?; + let mut active = BTreeMap::::new(); + for (_, start, i) in events { + work.tick().await?; + let c = &report.cells[i]; + let y = Coord(c.min[1]); + if start { + if active.range(..=y).next_back().is_some_and(|(_, end)| *end > c.min[1]) + || active.range(y..).next().is_some_and(|(start, _)| start.0 < c.max[1]) + { + return invalid("overlapping stock cells"); + } + active.insert(y, c.max[1]); + } else { + active.remove(&y); + } + } + // Exact coplanar rectangle merging reduces output without changing height. + let mut cells = Vec::new(); + for c in &report.cells { + work.tick().await?; + if c.high > bottom { + cells.push(c.clone()); + } + } + for _ in 0..24 { + let before = cells.len(); + for axis in 0..2 { + let other = 1 - axis; + work.sort(&mut cells, |a, b| { + a.min[other] + .total_cmp(&b.min[other]) + .then(a.max[other].total_cmp(&b.max[other])) + .then(a.high.total_cmp(&b.high)) + .then(a.min[axis].total_cmp(&b.min[axis])) + }) + .await?; + let mut merged: Vec = Vec::new(); + for c in cells { + work.tick().await?; + if let Some(last) = merged.last_mut() + && last.min[other] == c.min[other] + && last.max[other] == c.max[other] + && last.high == c.high + && last.max[axis] == c.min[axis] + { + last.max[axis] = c.max[axis]; + } else { + merged.push(c); + } + } + cells = merged; + } + if cells.len() == before { + break; + } + } + if cells.len() > MAX_CELLS { + return invalid(format!("stock mesh has {} merged cells, exceeding 60000; use validation JSON for this resolution", cells.len())); + } + Ok(cells) +} + +async fn spans bool, Y: FnMut() -> F, F: Future>(cells: &[Cell], work: &mut Work) -> Result> { + let mut lines: BTreeMap<(usize, Coord), Vec> = BTreeMap::new(); + for (i, c) in cells.iter().enumerate() { + work.tick().await?; + for axis in 0..2 { + for (fixed, side) in [(c.min[axis], 1), (c.max[axis], 0)] { + let events = lines.entry((axis, Coord(fixed))).or_default(); + events.push(Event { at: Coord(c.min[1 - axis]), cell: i, side, start: true }); + events.push(Event { at: Coord(c.max[1 - axis]), cell: i, side, start: false }); + } + } + } + let mut spans = Vec::new(); + for ((axis, fixed), mut events) in lines { + work.sort(&mut events, |a, b| (a.at, a.start).cmp(&(b.at, b.start))).await?; + let mut active = [None; 2]; + let mut previous: Option = None; + for event in events { + work.tick().await?; + if let Some(a) = previous + && a < event.at + && active != [None; 2] + { + spans.push(Span { axis, fixed: fixed.0, a: a.0, b: event.at.0, cells: active }); + } + previous = Some(event.at); + if event.start { + if active[event.side].is_some() { + return invalid("overlapping stock boundaries"); + } + active[event.side] = Some(event.cell); + } else { + active[event.side] = None; + } + } + } + Ok(spans) +} + +async fn close_tiny_saddles bool, Y: FnMut() -> F, F: Future>( + cells: &mut [Cell], + spans: &[Span], + bottom: f64, + work: &mut Work, +) -> Result<()> { + // NW, NE, SE, SW occupancy at every boundary endpoint, including T-junctions. + let mut vertices = BTreeMap::; 4]>::new(); + for s in spans { + for (side, id) in s.cells.iter().enumerate() { + work.tick().await?; + let Some(id) = id else { continue }; + let (a, b) = match (s.axis, side) { + (0, 0) => (0, 3), + (0, 1) => (1, 2), + (1, 0) => (2, 3), + _ => (1, 0), + }; + vertices.entry(s.xy(s.a)).or_insert([None; 4])[a] = Some(*id); + vertices.entry(s.xy(s.b)).or_insert([None; 4])[b] = Some(*id); + } + } + let mut ceilings = Vec::with_capacity(cells.len()); + for c in cells.iter() { + work.tick().await?; + ceilings.push(c.high + MAX_SADDLE_FILL); + } + // Monotone bounded closure. Stop rather than silently exceeding either the + // error or work bound; the final manifold validator rejects unresolved cases. + for _ in 0..8 { + let mut changed = false; + for ids in vertices.values() { + work.tick().await?; + let heights = ids.map(|id| id.map_or(bottom, |i| cells[i].high)); + for (upper, lower) in [([0, 2], [1, 3]), ([1, 3], [0, 2])] { + let threshold = heights[upper[0]].min(heights[upper[1]]); + let pick = if heights[lower[0]] >= heights[lower[1]] { lower[0] } else { lower[1] }; + if threshold > heights[pick] + && let Some(id) = ids[pick] + && threshold <= ceilings[id] + { + cells[id].high = threshold; + changed = true; + } + } + } + if !changed { + break; + } + } + for _ in vertices { + work.tick().await?; + } + Ok(()) +} + +pub(crate) fn validate_triangles(triangles: &[[Vec3; 3]]) -> Result<()> { + ready(validate(triangles, &mut Work { cancelled: || false, yield_control: || std::future::ready(()), remaining: 0 })) +} +async fn validate bool, Y: FnMut() -> F, F: Future>(triangles: &[[Vec3; 3]], work: &mut Work) -> Result<()> { + if triangles.len() > MAX_TRIANGLES { + return invalid("stock triangle budget exceeded"); + } + for t in triangles { + work.tick().await?; + for p in t { + crate::model::point(*p)?; + } + if (t[1] - t[0]).cross(t[2] - t[0]).len2() == 0. { + return invalid("degenerate stock triangle"); + } + } + + // Edge incidence plus connected vertex links reject cracks, T-junctions, + // touching components and non-manifold saddle edges explicitly. + let mut edges = BTreeMap::<(Point, Point), (usize, i32)>::new(); + let mut links = BTreeMap::>::new(); + for t in triangles { + work.tick().await?; + let p = t.map(point); + for (a, b, c) in [(p[0], p[1], p[2]), (p[1], p[2], p[0]), (p[2], p[0], p[1])] { + let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) }; + let edge = edges.entry(key).or_default(); + edge.0 += 1; + edge.1 += sign; + if edge.0 > 2 { + return invalid(format!("stock boundary is non-manifold at {key:?}; use validation JSON")); + } + links.entry(a).or_default().push((b, c)); + } + } + for (_, edge) in edges { + work.tick().await?; + if edge != (2, 0) { + return invalid("stock boundary has an unstitched or misoriented edge"); + } + } + // Consume the maps incrementally too: dropping a large tree all at once + // can be as visible as constructing it on the browser's event loop. + for (_, link) in links { + let mut next = BTreeMap::new(); + for (a, b) in &link { + work.tick().await?; + if next.insert(*a, *b).is_some() { + return invalid("non-manifold stock vertex"); + } + } + let Some((&start, _)) = next.first_key_value() else { + continue; + }; + let mut at = start; + for _ in 0..link.len() { + work.tick().await?; + let Some(b) = next.remove(&at) else { + return invalid("disconnected stock vertex surface; use validation JSON"); + }; + at = b; + } + if at != start || !next.is_empty() { + return invalid("open stock vertex surface"); + } + } + Ok(()) +} + +struct Mesh { + triangles: Vec<[Vec3; 3]>, +} +impl Mesh { + fn triangle(&mut self, a: Vec3, b: Vec3, c: Vec3) -> Result<()> { + if self.triangles.len() >= MAX_TRIANGLES { + return invalid("stock mesh exceeds 500000 triangles"); + } + if (b - a).cross(c - a).len2() == 0.0 { + return invalid("degenerate stock boundary triangle"); + } + self.triangles.push([a, b, c]); + Ok(()) + } + async fn convex bool, Y: FnMut() -> F, F: Future>( + &mut self, + boundary: &[Vec3], + reverse: bool, + work: &mut Work, + ) -> Result<()> { + // All faces are rectangles with collinear boundary subdivisions. Clip + // the smallest nonzero corner first, preserving every boundary vertex. + // An ordered corner set bounds this to O(n log n), including long edges. + let n = boundary.len(); + if !(3..=MAX_TRIANGLES).contains(&n) { + return invalid("invalid stock polygon size"); + } + let mut prev: Vec<_> = (0..n).map(|i| (i + n - 1) % n).collect(); + let mut next: Vec<_> = (0..n).map(|i| (i + 1) % n).collect(); + let area = |a: usize, b: usize, c: usize| Coord((boundary[b] - boundary[a]).cross(boundary[c] - boundary[b]).len2()); + let mut corners = BTreeSet::new(); + for i in 0..n { + work.tick().await?; + let a = area(prev[i], i, next[i]); + if a.0 > 0.0 { + corners.insert((a, i)); + } + } + for _ in 0..n - 2 { + work.tick().await?; + let (_, i) = corners.pop_first().ok_or_else(|| Error("degenerate stock polygon".into()))?; + let a = prev[i]; + let b = next[i]; + if reverse { + self.triangle(boundary[a], boundary[b], boundary[i])?; + } else { + self.triangle(boundary[a], boundary[i], boundary[b])?; + } + corners.remove(&(area(prev[a], a, next[a]), a)); + corners.remove(&(area(prev[b], b, next[b]), b)); + next[a] = b; + prev[b] = a; + for k in [a, b] { + let v = area(prev[k], k, next[k]); + if v.0 > 0.0 { + corners.insert((v, k)); + } + } + } + Ok(()) + } +} + +async fn boundary bool, Y: FnMut() -> F, F: Future>(report: &Report, bottom: f64, work: &mut Work) -> Result { + let mut cells = cells(report, bottom, work).await?; + let spans = spans(&cells, work).await?; + close_tiny_saddles(&mut cells, &spans, bottom, work).await?; + let mut levels = BTreeMap::>::new(); + for s in &spans { + work.tick().await?; + for xy in [s.xy(s.a), s.xy(s.b)] { + let z = levels.entry(xy).or_default(); + z.insert(Coord(bottom)); + for id in s.cells.into_iter().flatten() { + z.insert(Coord(cells[id].high)); + } + } + } + let mut mesh = Mesh { triangles: Vec::new() }; + let mut tops: Vec> = vec![Vec::new(); cells.len()]; + for s in &spans { + work.tick().await?; + for (side, id) in s.cells.iter().enumerate() { + let Some(id) = id else { + continue; + }; + let c = &cells[*id]; + let edge = match (s.axis, side) { + (1, 1) => 0, + (0, 0) => 1, + (1, 0) => 2, + _ => 3, + }; + let a = if edge < 2 { s.a } else { s.b }; + tops[*id].push((edge, Coord(if edge < 2 { a } else { -a }), s.vertex(a, c.high))); + } + let heights = s.cells.map(|id| id.map_or(bottom, |id| cells[id].high)); + if heights[0] == heights[1] { + continue; + } + let lo = heights[0].min(heights[1]); + let hi = heights[0].max(heights[1]); + let mut polygon = vec![s.vertex(s.a, lo), s.vertex(s.b, lo)]; + let right = levels.get(&s.xy(s.b)).ok_or_else(|| Error("missing stock edge levels".into()))?; + for z in right.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))) { + work.tick().await?; + polygon.push(s.vertex(s.b, z.0)); + } + polygon.push(s.vertex(s.b, hi)); + polygon.push(s.vertex(s.a, hi)); + let left = levels.get(&s.xy(s.a)).ok_or_else(|| Error("missing stock edge levels".into()))?; + for z in left.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))).rev() { + work.tick().await?; + polygon.push(s.vertex(s.a, z.0)); + } + mesh.convex(&polygon, (s.axis == 0) != (heights[0] > heights[1]), work).await?; + } + for mut top in tops { + work.sort(&mut top, |a, b| (a.0, a.1).cmp(&(b.0, b.1))).await?; + let mut polygon: Vec<_> = top.into_iter().map(|p| p.2).collect(); + mesh.convex(&polygon, false, work).await?; + for p in &mut polygon { + p.z = bottom; + } + mesh.convex(&polygon, true, work).await?; + } + for _ in levels { + work.tick().await?; + } + validate(&mesh.triangles, work).await?; + Ok(mesh) +} + +pub(crate) fn triangles(report: &Report, bottom: f64) -> Result> { + ready(triangles_cooperative(report, bottom, || false, || std::future::ready(()))) +} +pub(crate) async fn triangles_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result> { + let mut work = Work { cancelled, yield_control, remaining: 0 }; + Ok(boundary(report, bottom, &mut work).await?.triangles) +} + +pub(crate) fn stl(report: &Report, bottom: f64) -> Result { + ready(stl_cooperative(report, bottom, || false, || std::future::ready(()))) +} +pub(crate) async fn stl_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + let mut work = Work { cancelled, yield_control, remaining: 0 }; + let mesh = boundary(report, bottom, &mut work).await?.triangles; + let mut out = String::from("solid cam_remaining_stock\n"); + for triangle in mesh { + work.tick().await?; + out.push_str("facet normal 0 0 0\nouter loop\n"); + for p in triangle { + out.push_str(&format!("vertex {} {} {}\n", p.x, p.y, p.z)); + } + out.push_str("endloop\nendfacet\n"); + if out.len() > 128 * 1024 * 1024 { + return invalid("stock mesh exceeds 128 MiB"); + } + } + out.push_str("endsolid cam_remaining_stock\n"); + work.remaining = 0; + work.tick().await?; + Ok(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::verify::Status; + fn cell(min: [f64; 2], max: [f64; 2], high: f64) -> Cell { + Cell { min, max, low: high, high, target_low: high, target_high: high } + } + fn report(cells: Vec) -> Report { + Report { + version: crate::VERSION.into(), + fingerprint: "analytic".into(), + status: Status::Pass, + finished_dimensions: Status::Pass, + post_version: "analytic".into(), + tolerance: 0.01, + uncertainty: 0.0025, + maximum_height_interval: 0.0, + stock_complete: true, + stock: None, + cells, + findings: vec![], + cutting_distance: 0.0, + rapid_distance: 0.0, + cutting_minutes: 0.0, + remaining_volume_low: 0.0, + remaining_volume_high: 0.0, + work: 0, + } + } + fn check(cells: Vec, volume: f64, area: f64) -> Vec<[Vec3; 3]> { + let text = stl(&report(cells), 0.0).unwrap(); + let mesh = crate::compare::read_stl(text.as_bytes()).unwrap(); + let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + let a: f64 = mesh.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).len() / 2.0).sum(); + assert!((v - volume).abs() < 1e-9, "volume {v} expected {volume}"); + assert!((a - area).abs() < 1e-9, "area {a} expected {area}"); + mesh + } + #[test] + fn box_and_step_have_only_exterior_faces() { + check(vec![cell([0., 0.], [1., 1.], 1.)], 1., 6.); + let mesh = check(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 0.], [2., 1.], 2.)], 3., 14.); + for t in mesh { + if t.iter().all(|p| p.x == 1.) { + assert!(t.iter().all(|p| p.z >= 1.)); + } + } + } + #[test] + fn adaptive_t_junctions_and_three_height_vertices_are_stitched() { + check(vec![cell([0., 0.], [1., 2.], 1.), cell([1., 0.], [2., 1.], 2.), cell([1., 1.], [2., 2.], 3.)], 7., 26.); + } + #[test] + fn hole_and_disconnected_stock_are_watertight() { + let mut cells = Vec::new(); + for x in 0..3 { + for y in 0..3 { + cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], if x == 1 && y == 1 { 0. } else { 1. })); + } + } + check(cells, 8., 32.); + check(vec![cell([0., 0.], [1., 1.], 1.), cell([2., 0.], [3., 1.], 1.)], 2., 12.); + } + #[test] + fn nonmanifold_touching_stock_is_explicitly_rejected() { + assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 1.], [2., 2.], 1.)]), 0.).is_err()); + assert!( + stl( + &report(vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 1.), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]), + 0. + ) + .is_err() + ); + } + #[test] + fn tiny_adaptive_saddle_closure_is_conservative_and_bounded() { + let cells = + vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 2. - 4e-6), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]; + let mut original = report(cells); + original.stock = Some(crate::Stock { min: Vec3::new(0., 0., 0.), max: Vec3::new(2., 2., 2.) }); + let mesh = triangles(&original, 0.).unwrap(); + let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.).sum(); + assert!((volume - 7.).abs() < 1e-12); + assert_eq!(original.cells[1].high, 2. - 4e-6); + assert_eq!(crate::compare::stock_mesh(&original, &mesh, 0., crate::TOLERANCE).unwrap().status, Status::Pass); + let mut too_large = original; + too_large.cells[1].high = 2. - 2. * MAX_SADDLE_FILL; + assert!(triangles(&too_large, 0.).is_err()); + } + #[test] + fn hostile_and_overlapping_cells_are_rejected() { + for bad in [cell([0., 0.], [0., 1.], 1.), cell([0., 0.], [1., 1.], f64::NAN), cell([0., 0.], [1., 1.], -1.)] { + assert!(stl(&report(vec![bad]), 0.).is_err()); + } + assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([1., 1.], [3., 3.], 2.)]), 0.).is_err()); + assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([0., 0.], [2., 2.], 1.)]), 0.).is_err()); + } + #[test] + fn zero_stock_and_translated_base_are_preserved() { + assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 0.)]), 0.).unwrap().lines().count() == 2); + let mesh = triangles(&report(vec![cell([-3., 2.], [-2., 3.], 7.)]), 5.).unwrap(); + let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + assert!((v - 2.).abs() < 1e-9); + } + #[test] + fn incomplete_verification_cannot_export_partial_stock() { + let input = crate::corpus::fixture("face").unwrap(); + let path = crate::generate::generate(input.clone()).unwrap(); + let nc = crate::nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + for limits in [crate::verify::Limits { cells: 1, ..Default::default() }, crate::verify::Limits { work: 1, ..Default::default() }] { + let report = crate::verify::simulate(&input, &nc, limits).unwrap(); + assert!(!report.stock_complete); + assert!(stl(&report, 0.).is_err()); + } + } + #[test] + fn cooperative_mesh_matches_sync_and_cancels_during_topology_work() { + use std::task::{Context, Poll, Waker}; + let report = report( + (0..32) + .rev() + .flat_map(|x| (0..32).rev().map(move |y| cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64))) + .collect(), + ); + let expected = triangles(&report, 0.).unwrap(); + let yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) }) + }; + let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || false, yield_once)); + let mut cx = Context::from_waker(Waker::noop()); + let mut polls = 0; + loop { + polls += 1; + if let Poll::Ready(result) = future.as_mut().poll(&mut cx) { + assert_eq!(result.unwrap(), expected); + break; + } + assert!(polls < 100_000); + } + assert!(polls > 100); + // Cancel in cell sorting, boundary work and late manifold validation. + // Each suspended future must fail on its next poll without a partial mesh. + for cancel_after in [1, polls / 2, polls - 1] { + let cancelled = std::cell::Cell::new(false); + let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || cancelled.get(), yield_once)); + for _ in 0..cancel_after { + assert!(future.as_mut().poll(&mut cx).is_pending()); + } + cancelled.set(true); + let result = future.as_mut().poll(&mut cx); + assert!(matches!(result, Poll::Ready(Err(e)) if e.0.contains("cancelled"))); + } + } + #[test] + fn stepped_grid_preserves_volume_and_surface_area() { + let n = 16; + let mut cells = Vec::new(); + for x in 0..n { + for y in 0..n { + cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64)); + } + } + check(cells, (n * n * n) as f64, (6 * n * n + 2 * n * (n - 1)) as f64); + } +} diff --git a/crates/cam/src/surface.rs b/crates/cam/src/surface.rs new file mode 100644 index 00000000..eb4c3b9c --- /dev/null +++ b/crates/cam/src/surface.rs @@ -0,0 +1,308 @@ +//! Local no-gouge certificates for the vertical height-field target solid. +//! +//! To bound distance to the solid's complement, choose an XY point q with a +//! conservative upper target height H(q). Every point p in a candidate removed +//! column has an exterior point (q, max(p.z,H(q))) at distance no more than +//! hypot(max_xy_distance(p,q), max(H(q)-column_low,0)). This covers whole cells, +//! not sampled surface points, and remains valid for overlapping target bodies. +use crate::{ + spatial::Index, + verify::{Limits, Status}, + *, +}; +use solidcraft_geom::Vec3; + +pub(crate) struct Surface<'a> { + triangles: &'a [[Vec3; 3]], + index: Index, + bottom: f64, + guard: f64, +} +pub(crate) struct Region { + pub center: [f64; 2], + pub half: f64, + pub low: f64, + pub high: f64, + pub target_high: f64, + pub tolerance: f64, +} +impl<'a> Surface<'a> { + pub(crate) fn new(triangles: &'a [[Vec3; 3]], bottom: f64) -> Result { + let extent = triangles.iter().flatten().map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())).fold(1., f64::max); + let guard = 128. * f64::EPSILON * extent; + let items: Vec<_> = triangles + .iter() + .enumerate() + .filter(|(_, t)| (t[1] - t[0]).cross(t[2] - t[0]).z > 0.) + .map(|(id, t)| { + let min = t.iter().copied().fold(t[0], Vec3::min); + let max = t.iter().copied().fold(t[0], Vec3::max); + (id, [min.x - guard, min.y - guard], [max.x + guard, max.y + guard]) + }) + .collect(); + if items.is_empty() { + return invalid("protected target has no upward height-field surface"); + } + Ok(Self { triangles, index: Index::new(items), bottom, guard }) + } + pub(crate) fn bounds(&self, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) { + let mut ids = Vec::new(); + self.index.query([p[0] - half, p[1] - half], [p[0] + half, p[1] + half], &mut ids); + let local: Vec<_> = ids.into_iter().map(|i| self.triangles[i]).collect(); + crate::verify::target_height(p, half, &local, self.bottom, work, budget) + } + async fn height>( + &self, + p: [f64; 2], + work: &mut usize, + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let mut ids = Vec::new(); + self.index.query([p[0] - self.guard, p[1] - self.guard], [p[0] + self.guard, p[1] + self.guard], &mut ids); + let mut height = self.bottom; + for (count, id) in ids.into_iter().enumerate() { + if count % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("surface verification cancelled"); + } + } + *work += 1; + if *work > limits.work { + return Ok(None); + } + let t = &self.triangles[id]; + if let Some((_, upper)) = height_interval(t, p, self.guard) { + height = height.max(upper); + } + } + Ok(Some(height + self.guard)) + } + pub(crate) async fn protect>( + &self, + r: Region, + work: &mut usize, + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result { + let tolerance = r.tolerance - POST_ERROR - VERIFY_ERROR - self.guard; + if tolerance > 0. && r.target_high + self.guard - r.low <= tolerance { + return Ok(Status::Pass); + } + if r.half < tolerance { + // The search locates a certificate. Failure to find one never proves + // a gouge and never becomes a clearance pass. + let reach = tolerance - r.half; + for scale in [0., 0.5, 0.95] { + for direction in 0..8 { + if scale == 0. && direction != 0 { + continue; + } + let angle = direction as f64 * std::f64::consts::FRAC_PI_4; + let offset = scale * reach; + let q = [r.center[0] + offset * angle.cos(), r.center[1] + offset * angle.sin()]; + let Some(height) = self.height(q, work, limits, cancelled, yield_control).await? else { + return Ok(Status::Inconclusive); + }; + // Include coordinate rounding in the actual computed offset. + let lateral = r.half + (q[0] - r.center[0]).hypot(q[1] - r.center[1]); + if lateral.hypot((height - r.low).max(0.)) + self.guard <= tolerance { + return Ok(Status::Pass); + } + } + } + } + // A positive failure certificate: a ball larger than the tolerance is + // contained in one target triangle's downward extrusion, while its center + // is definitely removed. No coverage sums or assumed mesh watertightness. + let radius = r.tolerance + POST_ERROR + VERIFY_ERROR + self.guard; + let z = (r.high + self.guard).max(self.bottom + 2. * radius); + if z - radius > self.bottom { + let mut ids = Vec::new(); + self.index.query(r.center, r.center, &mut ids); + for (count, id) in ids.into_iter().enumerate() { + if count % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("surface verification cancelled"); + } + } + *work += 1; + if *work > limits.work { + return Ok(Status::Inconclusive); + } + let t = &self.triangles[id]; + let covered = [[-radius, -radius], [radius, -radius], [radius, radius], [-radius, radius]] + .iter() + .all(|d| height_interval(t, [r.center[0] + d[0], r.center[1] + d[1]], self.guard).is_some_and(|(lower, _)| lower > z + radius)); + if covered { + return Ok(Status::Fail); + } + } + } + Ok(Status::Inconclusive) + } +} + +// Upper is conservative even on a projected triangle edge. Lower is -infinity +// near edges or degenerate projections so it cannot certify an interior ball. +fn height_interval(t: &[Vec3; 3], p: [f64; 2], guard: f64) -> Option<(f64, f64)> { + let n = (t[1] - t[0]).cross(t[2] - t[0]); + if n.z <= 0. { + return None; + } + let mut near = false; + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + let edge = (b.x - a.x) * (p[1] - a.y) - (b.y - a.y) * (p[0] - a.x); + let error = guard * (1. + (b.x - a.x).abs() + (b.y - a.y).abs()); + if edge < -error { + return None; + } + near |= edge <= error; + } + let hi = t.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max); + if near || n.z <= guard * guard { + return Some((f64::NEG_INFINITY, hi + guard)); + } + let z = t[0].z + (n.x * (t[0].x - p[0]) + n.y * (t[0].y - p[1])) / n.z; + let error = guard * (1. + (n.x / n.z).abs() + (n.y / n.z).abs()); + if !z.is_finite() || !error.is_finite() { + return Some((f64::NEG_INFINITY, hi + guard)); + } + Some((z - error, (z + error).min(hi + guard))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn plate(lo: f64, hi: f64, z: f64) -> Vec<[Vec3; 3]> { + let a = Vec3::new(lo, lo, z); + let b = Vec3::new(hi, lo, z); + let c = Vec3::new(hi, hi, z); + let d = Vec3::new(lo, hi, z); + vec![[a, b, c], [a, c, d]] + } + fn protect(s: &Surface, r: Region, budget: usize) -> Status { + use std::future::Future; + let mut ready = || std::future::ready(()); + let mut work = 0; + let mut future = std::pin::pin!(s.protect(r, &mut work, Limits { work: budget, ..Default::default() }, &|| false, &mut ready)); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(r) => r.unwrap(), + _ => Status::Inconclusive, + } + } + fn region(x: f64, half: f64, low: f64, high: f64) -> Region { + Region { center: [x, 1.], half, low, high, target_high: 5., tolerance: 0.01 } + } + #[test] + fn vertical_boundary_is_certified_but_interior_gouge_fails() { + let tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1000), Status::Pass); + assert_eq!(protect(&s, region(0.1, 0.001, 4.9, 4.9), 1000), Status::Fail); + assert_eq!(protect(&s, region(0.001, 0.1, 1., 5.), 1000), Status::Inconclusive); + } + #[test] + fn overlapping_top_faces_do_not_create_false_exterior_points() { + let mut tris = plate(0., 2., 5.); + tris.extend(plate(-1., 3., 6.)); + let s = Surface::new(&tris, 0.).unwrap(); + let mut r = region(0.001, 0.001, 4.9, 4.9); + r.target_high = 6.; + assert_eq!(protect(&s, r, 1000), Status::Fail); + } + #[test] + fn budget_exhaustion_is_inconclusive() { + let tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1), Status::Inconclusive); + } + #[test] + fn missing_upward_faces_are_rejected() { + assert!(Surface::new(&[[Vec3::ZERO, Vec3::X, Vec3::X]], 0.).is_err()); + } + #[test] + fn shifted_coordinates_preserve_height_bounds() { + let tris = plate(999999., 1000001., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + let mut work = 0; + let (lo, hi, uncertain) = s.bounds([1000000., 1000000.], 0.001, &mut work, 1000); + assert!(!uncertain); + assert_eq!(lo, 5.); + assert_eq!(hi, 5.); + } + fn drilled_plate() -> JobInput { + corpus::fixture("drill").unwrap() + } + + #[test] + fn protected_drilled_plate_certifies_with_its_wall_and_cone() { + let input = drilled_plate(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Pass, "{:?}", report.findings); + assert_eq!(report.finished_dimensions, Status::Pass); + } + #[test] + fn wrong_hole_against_protected_cad_surface_is_a_gouge() { + let input = drilled_plate(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap().replace("X10.000000", "X10.100000"); + let parsed = nc::parse(&code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.kind == "gouge")); + } + #[test] + fn through_gouges_and_sloping_surfaces_use_spatial_distance() { + let mut tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.1, 0.001, 0., 0.), 1000), Status::Fail); + for t in &mut tris { + for p in t { + p.z = 5. + 10. * p.x; + } + } + let s = Surface::new(&tris, 0.).unwrap(); + let mut r = region(0.1, 0.001, 5.97, 5.97); + r.target_high = 6.02; + assert_eq!(protect(&s, r, 1000), Status::Pass); + let mut r = region(0.1, 0.001, 5.8, 5.8); + r.target_high = 6.02; + assert_eq!(protect(&s, r, 1000), Status::Fail); + } + #[test] + fn surface_queries_yield_and_cancel() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let tris: Vec<_> = (0..5000).flat_map(|_| plate(0., 2., 5.)).collect(); + let s = Surface::new(&tris, 0.).unwrap(); + let mut work = 0; + let cancel = std::cell::Cell::new(false); + let cancelled = || cancel.get(); + let mut yielding = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut future = std::pin::pin!(s.protect(region(0.1, 0.001, 4.9, 4.9), &mut work, Default::default(), &cancelled, &mut yielding)); + let mut context = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut context).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_)))); + } +} diff --git a/crates/cam/src/verify.rs b/crates/cam/src/verify.rs new file mode 100644 index 00000000..782b55df --- /dev/null +++ b/crates/cam/src/verify.rs @@ -0,0 +1,644 @@ +//! Independent bounded stock verifier. Adaptive cells carry height intervals; +//! unresolved intervals and exhausted work budgets never become a pass. +use crate::{ + nc::{Parsed, Segment}, + *, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Pass, + Fail, + Inconclusive, +} +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Information, + #[default] + Unresolved, + Error, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Finding { + #[serde(default)] + pub severity: FindingSeverity, + pub kind: String, + pub line: Option, + /// One-based interpreted NC record, not a tessellated arc segment. + #[serde(default)] + pub record: Option, + /// Present only for an independently checked SolidCraft post round trip. + #[serde(default)] + pub source: Option, + pub at: Vec3, + pub detail: String, +} +impl Finding { + fn priority(&self) -> (FindingSeverity, bool) { + (self.severity, self.line.is_none()) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Cell { + pub min: [f64; 2], + pub max: [f64; 2], + pub low: f64, + pub high: f64, + pub target_low: f64, + pub target_high: f64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Report { + pub version: String, + pub fingerprint: String, + pub status: Status, + /// Separate from path clearance. Unsupported finished surfaces stay inconclusive. + pub finished_dimensions: Status, + pub post_version: String, + pub tolerance: f64, + /// Requested spatial refinement budget, not a claim about height uncertainty. + pub uncertainty: f64, + pub maximum_height_interval: f64, + /// False when resource limits prevented evaluating all stock cells/motions. + #[serde(default)] + pub stock_complete: bool, + /// Comparison domain. Legacy reports without this cannot certify coverage. + #[serde(default)] + pub stock: Option, + pub cells: Vec, + pub findings: Vec, + pub cutting_distance: f64, + pub rapid_distance: f64, + pub cutting_minutes: f64, + pub remaining_volume_low: f64, + pub remaining_volume_high: f64, + pub work: usize, +} +impl Report { + /// Bounded representative witnesses. Repeated cells on one NC line must not + /// crowd out a definite failure found later in the verification traversal. + pub(crate) fn finding(&mut self, finding: Finding) { + if self.findings.iter().any(|old| old.kind == finding.kind && old.line == finding.line && old.severity == finding.severity) { + return; + } + if self.findings.len() < 200 { + self.findings.push(finding); + } else if let Some((index, old)) = self.findings.iter().enumerate().min_by_key(|(_, old)| old.priority()) + && old.priority() < finding.priority() + && let Some(slot) = self.findings.get_mut(index) + { + *slot = finding; + } + } +} +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub cells: usize, + pub work: usize, + pub uncertainty: f64, +} +impl Default for Limits { + fn default() -> Self { + Self { cells: 250_000, work: 30_000_000, uncertainty: VERIFY_ERROR } + } +} + +pub fn simulate(input: &JobInput, nc: &Parsed, limits: Limits) -> Result { + simulate_cancellable(input, nc, limits, || false) +} +pub fn simulate_cancellable(input: &JobInput, nc: &Parsed, limits: Limits, cancelled: impl Fn() -> bool) -> Result { + use std::future::Future; + let mut future = std::pin::pin!(simulate_cooperative(input, nc, limits, cancelled, || std::future::ready(()))); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => invalid("synchronous verifier unexpectedly yielded"), + } +} +/// Portable host-driven verification. A host supplies a yield future to bound a poll's work. +/// This contains no executor, OS thread, browser or generator dependency. +pub async fn simulate_cooperative>( + input: &JobInput, + nc: &Parsed, + limits: Limits, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + input.validate()?; + if limits.cells == 0 + || limits.cells > 1_000_000 + || limits.work == 0 + || limits.work > 100_000_000 + || !limits.uncertainty.is_finite() + || limits.uncertainty <= 0.0 + || limits.uncertainty > VERIFY_ERROR + { + return invalid("invalid verifier limits"); + } + if nc.work_offset != input.setup.work_offset { + return invalid("NC/setup work-offset mismatch"); + } + if input.geometry.target.is_empty() { + return invalid("no target geometry to verify"); + } + if input.geometry.target.len() + input.geometry.fixtures.len() > 200_000 { + return invalid("verification mesh limit"); + } + for t in input.geometry.target.iter().chain(&input.geometry.fixtures) { + for p in t { + crate::model::point(*p)?; + } + } + let segments = nc::segments(nc, &input.setup)?; + let tol = input.operations.iter().map(|o| o.tolerance).fold(TOLERANCE, f64::min); + let stock = input.setup.stock; + // Only motions whose cutter or non-cutting envelopes can reach stock need + // cell queries. Keep original sequence IDs for machine-state/withdrawal checks. + let motion_index = crate::spatial::Index::new( + segments + .iter() + .enumerate() + .filter_map(|(id, s)| { + let tool = input.tools.iter().find(|t| t.id == s.tool)?; + let z = s.from.z.min(s.to.z); + let mut radius = if z <= stock.max.z + POST_ERROR { tool.diameter / 2. + POST_ERROR } else { 0. }; + for (r, lift) in [(tool.shank_diameter / 2., tool.cutting_length), (tool.holder_diameter / 2., tool.overall_length)] { + if z + lift <= stock.max.z + POST_ERROR { + radius = radius.max(r + POST_ERROR); + } + } + if radius == 0. { + return None; + } + Some(( + id, + [s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius], + [s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius], + )) + }) + .collect(), + ); + let surface = crate::surface::Surface::new(&input.geometry.target, stock.min.z)?; + let finished = crate::dimensions::Finished::new(input, tol)?; + let dimension_resolution = finished.as_ref().map_or(limits.uncertainty, |f| limits.uncertainty.min(f.refinement)); + let mut r = Report { + version: VERSION.into(), + fingerprint: input.fingerprint.clone(), + status: Status::Pass, + finished_dimensions: if finished.is_some() { Status::Pass } else { Status::Inconclusive }, + post_version: "reference-three-axis/1".into(), + tolerance: tol, + uncertainty: limits.uncertainty, + maximum_height_interval: 0.0, + stock_complete: true, + stock: Some(stock), + cells: Vec::new(), + findings: Vec::new(), + cutting_distance: 0.0, + rapid_distance: 0.0, + cutting_minutes: 0.0, + remaining_volume_low: 0.0, + remaining_volume_high: 0.0, + work: 0, + }; + if finished.is_none() { + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "finished_region_unsupported".into(), + line: None, + at: stock.min, + detail: "Finished-region certification needs a tolerance larger than the post/interpolation error budget".into(), + }); + } + for s in &segments { + let d = s.from.dist(s.to); + if s.rapid { + r.rapid_distance += d; + } else { + r.cutting_distance += d; + r.cutting_minutes += d / s.feed; + } + } + crate::collision::fixtures(input, &segments, limits, &cancelled, &mut yield_control, &mut r).await?; + let mut pending = vec![([stock.min.x, stock.min.y], [stock.max.x, stock.max.y], 0u8)]; + let mut next_yield = 0; + while let Some((min, max, depth)) = pending.pop() { + if r.work >= next_yield { + next_yield = r.work.saturating_add(4096); + yield_control().await; + } + if cancelled() { + return invalid("simulation cancelled"); + } + if r.work > limits.work || r.cells.len() + pending.len() >= limits.cells { + r.stock_complete = false; + r.status = if r.status == Status::Fail { Status::Fail } else { Status::Inconclusive }; + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "budget_exhausted".into(), + line: None, + at: stock.min, + detail: "Adaptive verification incomplete; increase resource budget or simplify the job".into(), + }); + break; + } + let center = [(min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0]; + let half = ((max[0] - min[0]).hypot(max[1] - min[1])) / 2.0; + let mut low = stock.max.z; + let mut high = stock.max.z; + let mut collision = None; + let mut removal_line = None; + let mut candidates = Vec::new(); + motion_index.query(min, max, &mut candidates); + candidates.sort_unstable(); + for (query_index, segment_index) in candidates.into_iter().enumerate() { + let seg = &segments[segment_index]; + r.work += 1; + if query_index % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if r.work > limits.work { + break; + } + let Some(tool) = input.tools.iter().find(|t| t.id == seg.tool) else { + continue; + }; + let radius = tool.diameter / 2.0; + let (distance, zlo, zhi) = sweep(center, half, seg, radius); + // A vertical drill has exact radial extrema on a rectangular cell. + // Circumscribed disks are unnecessarily loose here and can create + // artificial saddles where coarse and fine cone cells meet. + let (near, far) = if tool.kind == ToolKind::TwistDrill && seg.from.x == seg.to.x && seg.from.y == seg.to.y { + radial_bounds(min, max, [seg.from.x, seg.from.y]) + } else if seg.from.z == seg.to.z || (seg.from.x == seg.to.x && seg.from.y == seg.to.y) { + lateral_bounds(min, max, seg) + } else { + // Sloping motion still uses a common covering disk: its upper + // height bound must cover the entire cell at the same pose. + ((distance - half).max(0.0), distance + half) + }; + let cone_low = if tool.kind == ToolKind::TwistDrill { near / (tool.tip_angle / 2.0).tan() } else { 0.0 }; + let cone_high = if tool.kind == ToolKind::TwistDrill { far / (tool.tip_angle / 2.0).tan() } else { 0.0 }; + let overlaps = near <= radius + POST_ERROR; + // A vertical withdrawal starts at the endpoint of the preceding feed + // with the same tool; this exact geometric fact needs no raster estimate. + let withdrawal = segment_index.checked_sub(1).and_then(|i| segments.get(i)).is_some_and(|previous| { + !previous.rapid + && previous.tool == seg.tool + && previous.to == seg.from + && seg.from.x == seg.to.x + && seg.from.y == seg.to.y + && seg.to.z >= seg.from.z + }); + if seg.rapid && overlaps && !withdrawal { + if zhi + cone_high < low - tol && far <= radius { + collision = Some(("rapid_through_stock", seg.line)); + } else if zlo + cone_low < high - tol { + collision = Some(("rapid_clearance_unresolved", seg.line)); + } + } else if !seg.rapid && overlaps { + low = low.min(zlo + cone_low).max(stock.min.z); + if far <= radius { + let next = high.min(zhi + cone_high).max(stock.min.z); + if next < high { + removal_line = Some(seg.line); + } + high = next; + } + } + // Shank and holder sweeps against remaining stock, separate from cutter removal. + for (rad, lift, label) in [ + (tool.shank_diameter / 2.0, tool.cutting_length, "shank_collision"), + (tool.holder_diameter / 2.0, tool.overall_length, "holder_collision"), + ] { + let (d, z, _) = sweep(center, half, seg, rad); + if d + half < rad && z + lift < low - tol { + collision = Some((label, seg.line)); + } else if d < rad + half && z + lift < high - tol { + collision = Some(("holder_clearance_unresolved", seg.line)); + } + } + } + let (target_low, target_high, _) = surface.bounds(center, (max[0] - min[0]).max(max[1] - min[1]) / 2.0, &mut r.work, limits.work); + let protection = surface + .protect( + crate::surface::Region { center, half, low, high, target_high, tolerance: tol }, + &mut r.work, + limits, + &cancelled, + &mut yield_control, + ) + .await?; + let rest = low - target_high > tol; + let expected = + if let Some(f) = &finished { f.envelope((center, half), &mut r.work, limits.work, &cancelled, &mut yield_control).await? } else { None }; + let dimension = expected.map(|e| e.classify(low, high)); + // Refine both the machined result and the requested finished features: + // an omitted hole must not hide inside one large untouched stock cell. + let unresolved = ((high - low > tol / 2.0 || protection == Status::Inconclusive || collision.is_some()) && half > limits.uncertainty) + || (dimension == Some(Status::Inconclusive) && half > dimension_resolution); + if unresolved && depth < 24 && r.work <= limits.work { + pending.extend([ + (min, center, depth + 1), + ([center[0], min[1]], [max[0], center[1]], depth + 1), + ([min[0], center[1]], [center[0], max[1]], depth + 1), + (center, max, depth + 1), + ]); + continue; + } + let at = Vec3::new(center[0], center[1], low); + if let Some(dimension) = dimension { + if dimension == Status::Fail { + r.finished_dimensions = Status::Fail; + r.finding(Finding { + record: None, + severity: FindingSeverity::Error, + source: None, + kind: "finished_region_error".into(), + line: removal_line, + at, + detail: expected.map_or_else(String::new, |e| e.description()), + }); + } else if dimension == Status::Inconclusive && r.finished_dimensions == Status::Pass { + r.finished_dimensions = Status::Inconclusive; + } + } + if let Some((kind, line)) = collision { + if !kind.ends_with("unresolved") { + r.status = Status::Fail; + } else if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + r.finding(Finding { + record: None, + severity: if kind.ends_with("unresolved") { FindingSeverity::Unresolved } else { FindingSeverity::Error }, + source: None, + kind: kind.into(), + line: Some(line), + at, + detail: "Swept cutter or non-cutting tool envelope intersects remaining stock".into(), + }); + } + if protection == Status::Fail { + r.status = Status::Fail; + r.finding(Finding { + record: None, + severity: FindingSeverity::Error, + source: None, + kind: "gouge".into(), + line: removal_line, + at, + detail: format!("A definitely removed point lies more than {:.6} mm inside the protected height-field solid", tol), + }); + } + // Rest is reported separately: intermediate roughing is not required to finish a part. + if rest && !r.findings.iter().any(|f| f.kind == "remaining_material") { + r.finding(Finding { + record: None, + severity: FindingSeverity::Information, + source: None, + kind: "remaining_material".into(), + line: None, + at, + detail: format!("At least {:.6} mm above target; may be intentional outside selected finished regions", low - target_high), + }); + } + if protection == Status::Inconclusive { + if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "protected_surface_unresolved".into(), + line: removal_line, + at, + detail: "Local surface-distance bounds cannot yet certify the protected CAD boundary at this resolution or work budget".into(), + }); + } + let area = (max[0] - min[0]) * (max[1] - min[1]); + r.remaining_volume_low += area * (low - stock.min.z); + r.remaining_volume_high += area * (high - stock.min.z); + r.maximum_height_interval = r.maximum_height_interval.max(high - low); + r.cells.push(Cell { min, max, low, high, target_low, target_high }); + } + if r.work > limits.work { + r.stock_complete = false; + if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + } + if (!r.stock_complete || r.status != Status::Pass) && r.finished_dimensions == Status::Pass { + r.finished_dimensions = Status::Inconclusive; + } + r.findings.sort_by_key(|finding| std::cmp::Reverse(finding.priority())); + for finding in &mut r.findings { + if let Some(line) = finding.line { + finding.record = nc.record_for_line(line).map(|i| i + 1); + finding.source = nc.source_for_line(line); + } + } + Ok(r) +} + +/// Distance to XY sweep and conservative height range over the part of a segment +/// intersecting a disk expanded by the cell radius. +fn sweep(p: [f64; 2], half: f64, s: &Segment, radius: f64) -> (f64, f64, f64) { + let dx = s.to.x - s.from.x; + let dy = s.to.y - s.from.y; + let l2 = dx * dx + dy * dy; + let t = if l2 > 1e-20 { ((p[0] - s.from.x) * dx + (p[1] - s.from.y) * dy) / l2 } else { 0.0 }; + let c = t.clamp(0.0, 1.0); + let d = (p[0] - s.from.x - c * dx).hypot(p[1] - s.from.y - c * dy); + let minimum = |r: f64| { + if l2 <= 1e-20 { + return s.from.z.min(s.to.z); + } + let perpendicular = ((p[0] - s.from.x).powi(2) + (p[1] - s.from.y).powi(2) - t * t * l2).max(0.0); + let dt = ((r * r - perpendicular).max(0.0) / l2).sqrt(); + let z0 = s.from.z + (s.to.z - s.from.z) * (t - dt).clamp(0.0, 1.0); + let z1 = s.from.z + (s.to.z - s.from.z) * (t + dt).clamp(0.0, 1.0); + z0.min(z1) + }; + (d, minimum(radius + half), minimum((radius - half).max(0.0))) +} +fn radial_bounds(min: [f64; 2], max: [f64; 2], center: [f64; 2]) -> (f64, f64) { + let axis = |i: usize| { + let near = (min[i] - center[i]).max(center[i] - max[i]).max(0.0); + let far = (min[i] - center[i]).abs().max((max[i] - center[i]).abs()); + (near, far) + }; + let (x, y) = (axis(0), axis(1)); + (x.0.hypot(y.0), x.1.hypot(y.1)) +} + +/// Conservative rectangle-to-segment distance bounds. The segment AABB gives +/// an additional lower bound, exact for axis-aligned sweeps. Distance to a +/// convex segment is convex, so its maximum over a rectangle is attained at a +/// corner. Circumscribed disks alone invent overlap along long straight walls +/// and force unnecessary refinement of already resolved stock. +fn lateral_bounds(min: [f64; 2], max: [f64; 2], s: &Segment) -> (f64, f64) { + let a = [s.from.x, s.from.y]; + let b = [s.to.x, s.to.y]; + let delta = [b[0] - a[0], b[1] - a[1]]; + let length2 = delta[0] * delta[0] + delta[1] * delta[1]; + let gap = |k: usize| (min[k] - a[k].max(b[k])).max(a[k].min(b[k]) - max[k]).max(0.); + let distance = |p: [f64; 2]| { + let t = if length2 > 0. { (((p[0] - a[0]) * delta[0] + (p[1] - a[1]) * delta[1]) / length2).clamp(0., 1.) } else { 0. }; + (p[0] - a[0] - t * delta[0]).hypot(p[1] - a[1] - t * delta[1]) + }; + let mut far = 0_f64; + for p in [min, [min[0], max[1]], max, [max[0], min[1]]] { + far = far.max(distance(p)); + } + let center_distance = distance([(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]); + let half = (max[0] - min[0]).hypot(max[1] - min[1]) / 2.; + let extent = min.into_iter().chain(max).chain(a).chain(b).map(f64::abs).fold(1_f64, f64::max); + let guard = 128. * f64::EPSILON * extent; + (((center_distance - half).max(gap(0).hypot(gap(1))) - guard).max(0.), far + guard) +} + +pub(crate) fn target_height(p: [f64; 2], half: f64, tris: &[[Vec3; 3]], bottom: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) { + let mut lo = f64::INFINITY; + let mut hi = bottom; + let mut area = 0.0; + for tri in tris { + *work += 1; + if *work > budget { + return (bottom, hi, true); + } + let [a, b, c] = *tri; + // Positive-Z faces form the top envelope of supported height-field solids. + if (b - a).cross(c - a).z <= 0.0 { + continue; + } + let mut poly = tri.to_vec(); + for (axis, bound, greater) in [(0, p[0] - half, true), (0, p[0] + half, false), (1, p[1] - half, true), (1, p[1] + half, false)] { + let old = std::mem::take(&mut poly); + if old.is_empty() { + break; + } + let value = |v: Vec3| if axis == 0 { v.x } else { v.y }; + let inside = |v: Vec3| if greater { value(v) >= bound } else { value(v) <= bound }; + for (a, b) in old.iter().zip(old.iter().cycle().skip(1)).take(old.len()) { + if inside(*a) { + poly.push(*a); + } + if inside(*a) != inside(*b) { + let t = (bound - value(*a)) / (value(*b) - value(*a)); + poly.push(*a + (*b - *a) * t); + } + } + } + if poly.len() < 3 { + continue; + } + let origin = poly[0]; + let covered = + poly.iter().zip(poly.iter().cycle().skip(1)).take(poly.len()).map(|(a, b)| (*a - origin).cross(*b - origin).z).sum::().abs() / 2.0; + if covered <= 0.0 { + continue; + } + area += covered; + for v in poly { + lo = lo.min(v.z); + hi = hi.max(v.z); + } + } + if area == 0.0 { + return (bottom, bottom, false); + } + let complete = area >= 4.0 * half * half * (1.0 - 1e-8); + (if complete { lo } else { bottom }, hi, !complete) +} +/// Export a conforming conservative stock boundary. Tiny saddle closure may add +/// at most `STOCK_EXPORT_ERROR` to a cell height; it never removes stock. +pub fn stock_stl(report: &Report, bottom: f64) -> Result { + crate::stock_mesh::stl(report, bottom) +} + +/// Incremental extraction and ASCII serialization. Cancellation returns no partial artifact. +pub async fn stock_stl_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + crate::stock_mesh::stl_cooperative(report, bottom, cancelled, yield_control).await +} + +/// The same conforming conservative boundary as `stock_stl`, without serialization. +/// Coordinates are in the setup frame. An empty mesh represents fully removed stock. +pub fn stock_triangles(report: &Report, bottom: f64) -> Result> { + crate::stock_mesh::triangles(report, bottom) +} + +/// Cancellation-aware boundary extraction, yielding during sorting, stitching, +/// triangulation and manifold checks. No partial mesh is returned. +pub async fn stock_triangles_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result> { + crate::stock_mesh::triangles_cooperative(report, bottom, cancelled, yield_control).await +} + +/// Independently evaluate a point on the upper stock surface. Used for analytic +/// acceptance probes, never as a substitute for whole-region verification. +pub fn probe_height(input: &JobInput, nc: &Parsed, p: [f64; 2]) -> Result { + let mut z = input.setup.stock.max.z; + for s in nc::segments(nc, &input.setup)? { + if s.rapid { + continue; + } + let t = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| Error("unknown probe tool".into()))?; + let (d, low, _) = sweep(p, 0.0, &s, t.diameter / 2.0); + if d <= t.diameter / 2.0 { + let point = if t.kind == ToolKind::TwistDrill { d / (t.tip_angle / 2.0).tan() } else { 0.0 }; + z = z.min(low + point); + } + } + Ok(z.max(input.setup.stock.min.z)) +} + +#[cfg(test)] +mod radial_tests { + use super::*; + #[test] + fn rectangle_radial_extrema_include_corners_and_interior_axis() { + assert_eq!(super::radial_bounds([3., 4.], [6., 8.], [0., 0.]), (5., 10.)); + assert_eq!(super::radial_bounds([-3., -4.], [3., 4.], [0., 0.]), (0., 5.)); + assert_eq!(super::radial_bounds([7., -2.], [10., 6.], [10., 2.]), (0., 5.)); + } + + #[test] + fn rectangle_sweep_bounds_cover_axis_diagonal_endcaps_and_translations() { + for offset in [0., 1_000_000.] { + let point = |x, y| Vec3::new(x + offset, y + offset, 0.); + for (a, b, min, max, expected) in [ + ([0., 0.], [10., 0.], [2., 3.], [4., 5.], (3., 5.)), + ([0., 0.], [0., 10.], [3., 2.], [5., 4.], (3., 5.)), + ([0., 0.], [0., 0.], [3., 4.], [6., 8.], (5., 10.)), + ([0., 0.], [1., 0.], [4., 4.], [7., 8.], (5., 10.)), + ([0., 0.], [10., 10.], [4., 4.], [6., 6.], (0., 2_f64.sqrt())), + ] { + let s = Segment { from: point(a[0], a[1]), to: point(b[0], b[1]), rapid: false, feed: 100., tool: 1, line: 1 }; + let (low, high) = lateral_bounds(min.map(|x| x + offset), max.map(|x| x + offset), &s); + assert!(low <= expected.0 && high >= expected.1, "{low} {high} {expected:?}"); + assert!(expected.0 - low < 1e-6 && high - expected.1 < 1e-6); + } + } + } +} diff --git a/crates/cam/tests/machine_state.rs b/crates/cam/tests/machine_state.rs new file mode 100644 index 00000000..a2f8e5bf --- /dev/null +++ b/crates/cam/tests/machine_state.rs @@ -0,0 +1,69 @@ +//! Hand-authored modal programs exercise NIST machine state independently of generation. +use solidcraft_cam::{Motion, corpus, generate, nc}; + +const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n"; + +#[test] +fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() { + let mut input = corpus::fixture("face").unwrap(); + let mut second = input.tools[0].clone(); + second.id = 2; + second.number = 2; + input.tools.push(second); + for number in [1, 2] { + let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect(); + assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]); + assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6"); + assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30"); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8")); + + let restarted = source.replace("S7000\n", "M3\n"); + let parsed = nc::parse(&restarted, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. }); + let segments = nc::segments(&parsed, &input.setup).unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].tool, number as u64); + assert_eq!(segments[0].to.x, 1.); + } +} + +#[test] +fn same_block_speed_and_coolant_commands_keep_both_effects() { + let input = corpus::fixture("face").unwrap(); + for coolant in ["M8", "M9", "M0"] { + let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. }); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7")); + let source = source.replace("S0", "S12000"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. }); + assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1); + } + let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. }); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6")); +} + +#[test] +fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect(); + assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80"); + assert_eq!(&lines[1..3], ["M5", "M9"]); + assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000")); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + assert_eq!(parsed.records.len(), path.blocks.len() + 3); + for record in &parsed.records[..3] { + assert!(parsed.source_for_line(record.line).is_none()); + } + for (i, record) in parsed.records[3..].iter().enumerate() { + let source = parsed.source_for_line(record.line).unwrap(); + assert_eq!(source.block, i + 1); + assert_eq!(source.operation, path.blocks[i].operation); + } +} diff --git a/crates/cam/tests/milling_direction.rs b/crates/cam/tests/milling_direction.rs new file mode 100644 index 00000000..ab180410 --- /dev/null +++ b/crates/cam/tests/milling_direction.rs @@ -0,0 +1,112 @@ +//! Independent direction checks for a right-handed cutter with M3 spindle rotation. +//! A climb cut keeps the material being finished to the right of tool travel. +use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc}; + +fn segments(input: &JobInput) -> Vec { + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + nc::segments(&parsed, &input.setup).unwrap() +} + +fn square(lo: f64, hi: f64, reversed: bool) -> Vec<[f64; 2]> { + let mut ring = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]]; + if reversed { + ring.reverse(); + } + ring +} + +// Collect continuous constant-depth cuts from the independently interpreted NC. +// Signed polygon area checks orientation without calling the offset adapter. +fn loops(input: &JobInput) -> Vec> { + let mut result = Vec::new(); + let mut current = Vec::new(); + for s in segments(input) { + if !s.rapid && (s.from.z - 5.).abs() < 1e-8 && (s.to.z - 5.).abs() < 1e-8 { + current.push(s); + } else if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + result.push(current); + } + result +} + +fn ccw(path: &[nc::Segment]) -> bool { + let origin = path[0].from; + assert!((path.last().unwrap().to - origin).len() < 1e-5, "cut must close"); + let area = path + .iter() + .map(|s| { + let a = s.from - origin; + let b = s.to - origin; + a.x * b.y - b.x * a.y + }) + .sum::(); + assert!(area.abs() > 1.); + area > 0. +} + +#[test] +fn facing_direction_matches_uncut_side_at_every_depth() { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.operations[0].climb = climb; + input.operations[0].stepdown = 0.5; + let cuts: Vec<_> = segments(&input).into_iter().filter(|s| !s.rapid && (s.from.z - s.to.z).abs() < 1e-8 && s.to.z < 6.).collect(); + assert_eq!(cuts.len(), 22); + for depth in cuts.as_chunks::<11>().0 { + assert!(depth.windows(2).all(|pair| pair[1].from.y > pair[0].from.y)); + // Advancing rows toward +Y leaves uncut material north of the tool. + // Right of a -X move is +Y; right of a +X move is -Y. + assert!(depth.iter().all(|s| (s.to.x < s.from.x) == climb)); + } + } +} + +#[test] +fn contour_direction_matches_inside_and_outside_material() { + for reversed in [false, true] { + for inside in [false, true] { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.tools[0].diameter = 2.; + input.operations[0].climb = climb; + input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: square(4., 16., reversed), islands: vec![] }, inside }; + let paths = loops(&input); + assert_eq!(paths.len(), 1); + // Pocket wall: material outside => CCW climb. Boss: inside => CW climb. + assert_eq!(ccw(&paths[0]), inside == climb, "inside={inside}, climb={climb}, reversed={reversed}"); + } + } + } +} + +#[test] +fn pocket_direction_preserves_opposite_outer_and_island_winding() { + for reversed in [false, true] { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.tools[0].diameter = 2.; + input.operations[0].climb = climb; + input.operations[0].geometry = + Strategy::Pocket { boundary: Boundary { outer: square(0., 20., reversed), islands: vec![square(4., 16., reversed)] } }; + let paths = loops(&input); + assert_eq!(paths.len(), 2); + let mut outer = 0; + let mut island = 0; + for path in &paths { + let is_outer = path.iter().any(|s| s.from.x < 2.); + if is_outer { + outer += 1; + } else { + island += 1; + } + assert_eq!(ccw(path), is_outer == climb, "outer={is_outer}, climb={climb}, reversed={reversed}"); + } + assert_eq!((outer, island), (1, 1)); + } + } +} diff --git a/crates/cam/tests/nc_incremental.rs b/crates/cam/tests/nc_incremental.rs new file mode 100644 index 00000000..cfff1c05 --- /dev/null +++ b/crates/cam/tests/nc_incremental.rs @@ -0,0 +1,116 @@ +//! Original programs exercise cooperative NC processing and its exact boundary limits. +use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc}; +use solidcraft_geom::Vec3; +use std::{ + cell::Cell, + future::Future, + task::{Context, Poll, Waker}, +}; + +fn yield_once() -> impl Future { + let mut first = true; + std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) }) +} +fn drive(future: impl Future>) -> (cam::Result, usize) { + let mut future = std::pin::pin!(future); + let mut polls = 0; + loop { + polls += 1; + assert!(polls < 20_000, "unbounded checkpoint count"); + if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + return (result, polls); + } + } +} +fn path(count: usize) -> Toolpath { + let mut blocks = vec![ + Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 6000. } }, + Block { operation: 1, motion: Motion::Coolant { on: true } }, + ]; + for i in 0..count - 6 { + blocks.push(Block { + operation: if i < count / 2 { 1 } else { 2 }, + motion: Motion::Linear { to: Vec3::new(if i % 2 == 0 { 1. } else { 19. }, 10., 5.), feed: 100. }, + }); + } + blocks.extend([ + Block { operation: 2, motion: Motion::Coolant { on: false } }, + Block { operation: 2, motion: Motion::Spindle { rpm: 0. } }, + Block { operation: 2, motion: Motion::Stop }, + ]); + Toolpath { fingerprint: "original-independent-test".into(), blocks } +} +#[test] +fn maximum_path_includes_startup_records_and_excess_is_rejected() { + let input = cam::corpus::fixture("face").unwrap(); + let mut path = path(cam::MAX_MOVES); + let (parsed, polls) = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)); + let parsed = parsed.unwrap(); + assert_eq!(parsed.records.len(), cam::MAX_MOVES + 3); + assert!(polls > 3000); + let last = parsed.records.last().unwrap(); + let source = parsed.source_for_line(last.line).unwrap(); + assert_eq!((source.block, source.operation), (cam::MAX_MOVES, 2)); + path.blocks.push(path.blocks.last().unwrap().clone()); + assert!(nc::post(&path, &input.setup).is_err()); + // External NC records remain bounded independently of the posting limit. + let mut external = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\n".to_owned(); + external.push_str(&"G0 Z15\n".repeat(cam::MAX_MOVES + 3)); + external.push_str("M30\n"); + assert!(nc::parse(&external, &input.tools).unwrap_err().to_string().contains("NC motion limit")); +} +#[test] +fn synchronous_and_cooperative_processing_keep_bytes_state_and_provenance() { + let input = cam::corpus::fixture("face").unwrap(); + let path = path(1024); + let source = nc::post(&path, &input.setup).unwrap(); + assert!(source.starts_with("(solidcraft-reference-3axis/2; not a qualified machine post)\n(Fingerprint original-independent-test)\nG21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\nG0 X0.000000 Y0.000000 Z15.000000\n(Operation 1)\nT1 M6\n")); + assert_eq!(drive(nc::post_cooperative(&path, &input.setup, || false, yield_once)).0.unwrap(), source); + let synchronous = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let cooperative = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)).0.unwrap(); + assert_eq!(serde_json::to_value(&synchronous).unwrap(), serde_json::to_value(&cooperative).unwrap()); + for record in &synchronous.records { + assert_eq!(synchronous.source_for_line(record.line), cooperative.source_for_line(record.line)); + } + let broken = source.replace("(Operation 2)", "G123"); + let ordinary = nc::parse(&broken, &input.tools).unwrap_err(); + let incremental = drive(nc::parse_cooperative(&broken, &input.tools, || false, yield_once)).0.unwrap_err(); + assert_eq!(ordinary.to_string(), incremental.to_string()); + assert!(incremental.to_string().contains("unsupported G code")); +} +#[test] +fn cancellation_during_each_stage_returns_no_partial_program_or_line_mapping() { + let input = cam::corpus::fixture("face").unwrap(); + let path = path(1024); + let count = Cell::new(0); + let full = drive(nc::round_trip_cooperative( + &path, + &input.setup, + &input.tools, + || false, + || { + count.set(count.get() + 1); + yield_once() + }, + )) + .0 + .unwrap(); + assert_eq!(full.records.len(), 1027); + // Every checkpoint, including post, reader and correspondence stages. + for stop in 0..=count.get() { + let at = Cell::new(0); + let result = drive(nc::round_trip_cooperative( + &path, + &input.setup, + &input.tools, + || at.get() >= stop, + || { + at.set(at.get() + 1); + yield_once() + }, + )) + .0; + assert!(result.unwrap_err().to_string().contains("cancelled"), "checkpoint {stop}"); + } +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 4fac21b0..b44e7aae 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-sketch = { workspace = true } solidcraft-kernel = { workspace = true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 00523f8e..a0b0d246 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2225,12 +2225,21 @@ pub struct DesignLink { pub feature: u64, } +fn read_manufacturing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let manufacturing = ::deserialize(d)?; + manufacturing.validate().map_err(serde::de::Error::custom)?; + Ok(manufacturing) +} + /// The document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { /// Named, renderer-independent setups. Device/job state is never saved here. #[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")] pub render: std::sync::Arc, + /// Manufacturing definitions; generated toolpaths remain session caches. + #[serde(default, deserialize_with = "read_manufacturing", skip_serializing_if = "solidcraft_cam::Manufacturing::is_empty")] + pub manufacturing: solidcraft_cam::Manufacturing, #[serde(default = "default_format")] pub format: String, /// The changes of `format` this design has while they are pending (no release has fixed @@ -2453,6 +2462,7 @@ impl Document { pub fn new(name: &str) -> Self { Document { render: Default::default(), + manufacturing: Default::default(), format: default_format(), format_pending: crate::format::pending_names(), name: name.into(), diff --git a/crates/doc/src/format/pending/manufacturing.rs b/crates/doc/src/format/pending/manufacturing.rs new file mode 100644 index 00000000..573e515c --- /dev/null +++ b/crates/doc/src/format/pending/manufacturing.rs @@ -0,0 +1,4 @@ +//! Persistent milling setups, stock, fixtures, local tools, ordered operations, NC programs +//! and durable profile/hole references. Older designs get empty manufacturing; explicit hole +//! coordinates without references stay explicit. Generated paths and validation are not saved. +//! Earlier released readers refuse the new format instead of discarding manufacturing. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d54bfd..380d7cb7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-sketch = { workspace = true } solidcraft-kernel = { workspace = true } diff --git a/crates/engine/src/cmd/cam.rs b/crates/engine/src/cmd/cam.rs new file mode 100644 index 00000000..ef9344d9 --- /dev/null +++ b/crates/engine/src/cmd/cam.rs @@ -0,0 +1,603 @@ +//! Manufacturing commands shared by UI, CLI and MCP. +use super::CommandSpec; +use crate::{EngineError, Result, Session, cam, manufacturing as jobs}; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("cam.holes.from_selection", "Use Selected Holes", from_holes) + .noundo() + .params("setup: ID, selections: [{kind:face,body,index,occurrence?:[IDs]}|{kind:profile,sketch,index,occurrence?:[IDs]}]; returns ordered holes, diameters and durable sources; face body is a world label unless occurrence is explicit"), + CommandSpec::new("cam.boundary.from_profile", "Use Sketch Profile", from_profile) + .noundo() + .params("setup: ID, sketch: feature ID, profile: index, occurrence?: [IDs]; returns boundary and durable source reference"), + CommandSpec::new("cam.setup.put", "Milling Setup", setup) + .at("MILLING", "SETUP") + .icon("box") + .params("setup: complete Setup object (create or replace by ID)"), + CommandSpec::new("cam.tool.put", "Tool Library", tool).at("MILLING", "SETUP").icon("hole").params("tool: complete Tool object"), + CommandSpec::new("cam.operation.put", "Milling Operation", operation) + .at("MILLING", "TOOLPATHS") + .icon("cam_path") + .params("operation: complete Operation object; strategy face, contour, pocket or drill"), + CommandSpec::new("cam.program.put", "NC Program", program) + .at("MILLING", "SETUP") + .icon("save") + .params("program: {id,name,setup,operations:[ordered IDs]}"), + CommandSpec::new("cam.operation.order", "Reorder Operations", order).params("program: ID, operations: permutation of existing operation IDs"), + CommandSpec::new("cam.delete", "Delete Manufacturing Item", delete) + .params("kind: setup|tool|operation|program, id; referenced items cannot be deleted"), + CommandSpec::new("cam.inspect", "Inspect Manufacturing", inspect) + .noundo() + .params("job?: ID; include_path?: bool; include_stock?: bool (world-space display mesh); motion?: completed block count; finding?: zero-based verification finding index"), + CommandSpec::new("cam.generate", "Generate Toolpaths", generate) + .at("MILLING", "TOOLPATHS") + .icon("cam_generate") + .noundo() + .params("program: ID, background?: bool (default false); returns job ID"), + CommandSpec::new("cam.job.status", "CAM Job Status", status).noundo().params("job: ID"), + CommandSpec::new("cam.job.cancel", "Cancel CAM Job", cancel).noundo().params("job: ID"), + CommandSpec::new("cam.simulate", "Simulate Toolpaths", simulate) + .at("MILLING", "VERIFY") + .icon("cam_verify") + .noundo() + .params("job: ID, background?: bool"), + CommandSpec::new("cam.post", "Post Reference G-code", post) + .at("MILLING", "EXPORT") + .icon("save") + .noundo() + .params("job: ID, path?: string, background?: bool; requires current passing verification; background output is published by cam.export.result"), + CommandSpec::new("cam.export", "Export CAM Artifact", export) + .noundo() + .params("job: ID, format: toolpath|setup_sheet|stock|validation, path?: string, background?: bool; background output is published by cam.export.result"), + CommandSpec::new("cam.export.result", "Collect CAM Export", export_result).noundo().params("job: ID; consumes completed output after checking freshness; writes the requested path or returns content"), +]; +fn from_holes(s: &mut Session, p: &Value) -> Result { + let setup = id(p, "setup")?; + let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?; + let (sources, selected) = jobs::holes::select(s, setup, decode(p, "selections")?)?; + Ok( + json!({"sources":sources,"holes":selected.iter().map(|h|h.center).collect::>(),"diameters":selected.iter().map(|h|h.diameter).collect::>()}), + ) +} +fn from_profile(s: &mut Session, p: &Value) -> Result { + let setup = id(p, "setup")?; + let sketch = id(p, "sketch")?; + let profile = id(p, "profile")? as usize; + let state = s.model.state(); + let profile = state + .sketches + .iter() + .find(|x| x.feature == sketch) + .and_then(|s| s.profiles.get(profile)) + .ok_or_else(|| EngineError::Other("selected profile not found".into()))?; + let source = cam::ProfileRef { + sketch, + curves: profile.curve_ids(), + occurrence: p.get("occurrence").map(|_| decode(p, "occurrence")).transpose()?.unwrap_or_default(), + }; + let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?; + let boundary = jobs::profile_boundary(s, setup, &source, cam::TOLERANCE)?; + Ok(json!({"boundary":boundary,"source":source})) +} +fn decode(p: &Value, key: &str) -> Result { + serde_json::from_value(p.get(key).cloned().ok_or_else(|| EngineError::Other(format!("missing {key}")))?) + .map_err(|e| EngineError::Other(e.to_string())) +} +fn id(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("{key} must be an ID"))) +} +macro_rules! put {($fn:ident,$key:literal,$ty:ty,$field:ident)=>{fn $fn(s:&mut Session,p:&Value)->Result{ + let item:$ty=decode(p,$key)?;let id=item.id;let mut m=s.doc.manufacturing.clone(); + if let Some(old)=m.$field.iter_mut().find(|x|x.id==id){*old=item;}else{m.$field.push(item);} + m.validate().map_err(jobs::error)?;s.doc_mut().manufacturing=m;Ok(json!({"id":id})) +}};} +put!(setup, "setup", cam::Setup, setups); +put!(tool, "tool", cam::Tool, tools); +put!(operation, "operation", cam::Operation, operations); +put!(program, "program", cam::Program, programs); +fn order(s: &mut Session, p: &Value) -> Result { + let program = id(p, "program")?; + let ids: Vec = decode(p, "operations")?; + let mut m = s.doc.manufacturing.clone(); + let target = m.programs.iter_mut().find(|x| x.id == program).ok_or_else(|| EngineError::Other("program not found".into()))?; + let mut a = target.operations.clone(); + let mut b = ids.clone(); + a.sort(); + b.sort(); + if a != b { + return Err(EngineError::Other("order must be a permutation of the program operations".into())); + } + target.operations = ids; + m.validate().map_err(jobs::error)?; + s.doc_mut().manufacturing = m; + Ok(json!({"program":program})) +} +fn delete(s: &mut Session, p: &Value) -> Result { + let id = id(p, "id")?; + let mut m = s.doc.manufacturing.clone(); + let removed = match p.get("kind").and_then(Value::as_str) { + Some("setup") => { + let n = m.setups.len(); + m.setups.retain(|x| x.id != id); + n != m.setups.len() + } + Some("tool") => { + let n = m.tools.len(); + m.tools.retain(|x| x.id != id); + n != m.tools.len() + } + Some("operation") => { + let n = m.operations.len(); + m.operations.retain(|x| x.id != id); + n != m.operations.len() + } + Some("program") => { + let n = m.programs.len(); + m.programs.retain(|x| x.id != id); + n != m.programs.len() + } + _ => return Err(EngineError::Other("unknown CAM item kind".into())), + }; + if !removed { + return Err(EngineError::Other("CAM item not found".into())); + } + m.validate().map_err(jobs::error)?; + s.doc_mut().manufacturing = m; + Ok(json!({"deleted":id})) +} +fn inspect(s: &mut Session, p: &Value) -> Result { + if p.get("job").is_none() { + if p.get("finding").is_some() + || p.get("motion").is_some() + || ["include_path", "include_stock"].iter().any(|key| p.get(key).and_then(Value::as_bool) == Some(true)) + { + return Err(EngineError::Other("job is required for result inspection".into())); + } + return Ok(json!({"manufacturing":s.doc.manufacturing,"jobs":s.cam_jobs.jobs.keys().collect::>()})); + } + let job = jobs::job(s, id(p, "job")?)?; + let mut result = status(s, p)?; + match job.state()? { + jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) if p.get("include_path").and_then(Value::as_bool) == Some(true) => { + result["toolpath"] = json!(path); + } + _ => {} + } + if let Some(motion) = p.get("motion") { + jobs::current(s, &job)?; + let motion = motion + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| EngineError::Other("motion must be a nonnegative block count".into()))?; + let path = match job.state()? { + jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) => path, + _ => return Err(EngineError::Other("generate before inspecting playback".into())), + }; + let state = cam::playback::at(&path, &job.input()?.setup, motion).map_err(jobs::error)?; + result["playback"] = json!(state); + let operation = job.input()?.operations.iter().find(|o| Some(o.id) == state.operation); + result["source"] = json!(operation.and_then(|o| o.source.as_ref())); + result["hole_sources"] = json!(operation.map(|o| &o.hole_sources)); + } + if let Some(index) = p.get("finding") { + jobs::current(s, &job)?; + let index = + index.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("finding must be a nonnegative index".into()))?; + let jobs::JobState::Verified(path, report) = job.state()? else { + return Err(EngineError::Other("simulate before inspecting findings".into())); + }; + let finding = report.findings.get(index).ok_or_else(|| EngineError::Other("finding index out of range".into()))?; + let input = job.input()?; + let operation = finding.source.and_then(|source| input.operations.iter().find(|o| o.id == source.operation)); + let code = cam::nc::post(&path, &input.setup).map_err(jobs::error)?; + let line = finding.line.and_then(|line| line.checked_sub(1)).and_then(|index| code.lines().nth(index)); + result["diagnostic"] = json!({"index":index,"finding":finding,"world":input.setup.frame.world(finding.at),"operation":operation, + "source":operation.and_then(|o|o.source.as_ref()),"hole_sources":operation.map(|o|&o.hole_sources),"gcode_line":line, + "playback":finding.source.map(|source|cam::playback::at(&path,&input.setup,source.block)).transpose().map_err(jobs::error)?}); + } + if p.get("include_stock").and_then(Value::as_bool) == Some(true) { + jobs::current(s, &job)?; + result["stock_mesh"] = serde_json::to_value(job.stock_mesh()?.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?; + } + Ok(result) +} +fn generate(s: &mut Session, p: &Value) -> Result { + let id = jobs::start(s, id(p, "program")?, p.get("background").and_then(Value::as_bool).unwrap_or(false))?; + status(s, &json!({"job":id})) +} +fn status(s: &mut Session, p: &Value) -> Result { + let id = id(p, "job")?; + let job = jobs::job(s, id)?; + #[cfg(target_arch = "wasm32")] + jobs::advance(&job); + let stale = jobs::current(s, &job).is_err(); + let mut v = json!({"job":id,"stale":stale,"progress":job.progress.load(Ordering::Relaxed),"fingerprint":job.input().ok().map(|input| &input.fingerprint)}); + match job.state()? { + jobs::JobState::Verifying(_) => v["state"] = json!("simulating"), + jobs::JobState::Preparing => v["state"] = json!("preparing"), + jobs::JobState::Running => v["state"] = json!("running"), + jobs::JobState::Cancelled => v["state"] = json!("cancelled"), + jobs::JobState::Failed(e) => { + v["state"] = json!("failed"); + v["error"] = json!(e); + } + jobs::JobState::Generated(path) => { + v["state"] = json!("generated"); + v["blocks"] = json!(path.blocks.len()); + } + jobs::JobState::Verified(path, r) => { + v["state"] = json!("verified"); + v["blocks"] = json!(path.blocks.len()); + v["stock_preview"] = match job.stock_mesh() { + Ok(Some(mesh)) => json!({"available":true,"triangles":mesh.triangles.len()}), + Ok(None) => json!({"available":false}), + Err(e) => json!({"available":false,"error":e.to_string()}), + }; + v["verification"] = json!({"status":r.status,"finished_dimensions":r.finished_dimensions,"stock_complete":r.stock_complete, + "findings":r.findings,"cells":r.cells.len(),"work":r.work}); + } + } + v["export"] = jobs::exports::status(&job)?; + Ok(v) +} +fn cancel(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + job.cancelled.store(true, Ordering::Relaxed); + job.set(jobs::JobState::Cancelled); + status(s, p) +} +fn simulate(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + if jobs::exports::busy(&job)? { + return Err(EngineError::Other("finish or cancel the current export first".into())); + } + let path = match job.state()? { + jobs::JobState::Generated(p) | jobs::JobState::Verified(p, _) => p, + _ => return Err(EngineError::Other("generate a path first; another job may still be running".into())), + }; + job.set(jobs::JobState::Verifying(path)); + jobs::dispatch(job, p.get("background").and_then(Value::as_bool).unwrap_or(false), true)?; + status(s, p) +} +fn output(p: &Value, content: String) -> Result { + if let Some(path) = p.get("path").and_then(Value::as_str) { + crate::io::write_atomic(std::path::Path::new(path), content.as_bytes(), false).map_err(|e| EngineError::Other(e.to_string()))?; + Ok(json!({"path":path,"bytes":content.len()})) + } else { + Ok(json!({"content":content})) + } +} +fn post(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let jobs::JobState::Verified(path, report) = job.state()? else { + return Err(EngineError::Other("simulate before posting".into())); + }; + if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete { + return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into())); + } + if p.get("background").and_then(Value::as_bool).unwrap_or(false) { + jobs::exports::start(job, "gcode".into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?; + return status(s, p); + } + cam::nc::round_trip(&path, &job.input()?.setup, &job.input()?.tools).map_err(jobs::error)?; + output(p, cam::nc::post(&path, &job.input()?.setup).map_err(jobs::error)?) +} +fn export(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let (path, report) = match job.state()? { + jobs::JobState::Generated(p) => (p, None), + jobs::JobState::Verified(p, r) => (p, Some(r)), + _ => return Err(EngineError::Other("no generated result".into())), + }; + let format = p.get("format").and_then(Value::as_str).unwrap_or("validation"); + if p.get("background").and_then(Value::as_bool).unwrap_or(false) { + jobs::exports::start(job, format.into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?; + return status(s, p); + } + output(p, crate::io::cam::artifact(job.input()?, &path, report.as_deref(), format)?) +} + +fn export_result(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let (content, destination) = jobs::exports::take(&job)?; + output(&destination.map_or_else(|| json!({}), |path| json!({"path":path})), content) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn wait_export(s: &mut Session, job: u64) -> Value { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let status = s.execute("cam.job.status", &json!({"job":job})).unwrap(); + if status["export"]["state"] != "running" { + return status; + } + assert!(std::time::Instant::now() < deadline, "export worker timeout"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + #[test] + fn background_exports_only_publish_complete_current_results_once() { + let mut s = configured(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + s.execute("cam.simulate", &json!({"job":job})).unwrap(); + for format in ["toolpath", "validation", "setup_sheet", "stock", "gcode"] { + let cmd = if format == "gcode" { "cam.post" } else { "cam.export" }; + let expected = s.execute(cmd, &json!({"job":job,"format":format})).unwrap()["content"].as_str().unwrap().to_owned(); + s.execute(cmd, &json!({"job":job,"format":format,"background":true})).unwrap(); + assert!(s.execute(cmd, &json!({"job":job,"format":format,"background":true})).is_err()); + let status = wait_export(&mut s, job); + assert_eq!(status["export"]["state"], "ready", "{status}"); + let actual = s.execute("cam.export.result", &json!({"job":job})).unwrap()["content"].as_str().unwrap().to_owned(); + match format { + "toolpath" | "validation" => { + assert_eq!(serde_json::from_str::(&actual).unwrap(), serde_json::from_str::(&expected).unwrap()) + } + "setup_sheet" => assert!(actual.contains("

Operation settings and selected geometry

")), + _ => assert_eq!(actual, expected), + } + assert!(s.execute("cam.export.result", &json!({"job":job})).is_err()); + } + let path = std::env::temp_dir().join(format!("solidcraft-cam-export-{}.nc", std::process::id())); + s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap(); + assert_eq!(wait_export(&mut s, job)["export"]["state"], "ready"); + assert!(!path.exists()); + s.execute("cam.export.result", &json!({"job":job})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("G21")); + std::fs::remove_file(&path).unwrap(); + s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap(); + wait_export(&mut s, job); + let mut tool = s.doc.manufacturing.tools[0].clone(); + tool.diameter += 0.1; + s.execute("cam.tool.put", &json!({"tool":tool})).unwrap(); + assert!(s.execute("cam.export.result", &json!({"job":job})).unwrap_err().to_string().contains("stale")); + assert!(!path.exists()); + } + #[test] + fn cancelling_a_background_export_cannot_publish_or_restart_it() { + let mut s = configured(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).unwrap(); + s.execute("cam.job.cancel", &json!({"job":job})).unwrap(); + assert_eq!(wait_export(&mut s, job)["export"]["state"], "cancelled"); + assert!(s.execute("cam.export.result", &json!({"job":job})).is_err()); + assert!(s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).is_err()); + } + fn configured() -> Session { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":4})).unwrap(); + let mut i = cam::corpus::fixture("face").unwrap(); + i.setup.models[0].body = s.model.state().bodies[0].name.clone(); + let bounds = s.model.state().bodies[0].mesh().bounds(); + i.setup.stock.min = bounds.min; + i.setup.stock.max = bounds.max + crate::geom::Vec3::Z; + i.operations[0].top = bounds.max.z + 1.0; + i.operations[0].bottom = bounds.max.z; + s.execute("cam.setup.put", &json!({"setup":i.setup})).unwrap(); + s.execute("cam.tool.put", &json!({"tool":i.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":i.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Plate","setup":1,"operations":[1]}})).unwrap(); + s + } + #[test] + fn save_undo_and_stale_generation() { + let mut s = configured(); + let saved = crate::io::write_design(&s.doc); + assert_eq!(crate::io::read_design(&saved).unwrap().manufacturing, s.doc.manufacturing); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + assert_eq!(r["state"], "generated"); + let id = r["job"].clone(); + s.execute("cam.operation.order", &json!({"program":1,"operations":[1]})).unwrap(); + s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap(); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], false); + } + #[test] + fn operation_order_survives_reopen_undo_redo_and_controls_generated_sequence() { + let mut s = configured(); + let mut second = s.doc.manufacturing.operations[0].clone(); + second.id = 2; + second.name = "Second facing pass".into(); + second.feed += 25.; + s.execute("cam.operation.put", &json!({"operation":second})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Ordered milling","setup":1,"operations":[1,2]}})).unwrap(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone(); + for invalid in [json!([1, 1]), json!([2]), json!([1, 3])] { + let before = s.doc.clone(); + assert!(s.execute("cam.operation.order", &json!({"program":1,"operations":invalid})).is_err()); + assert_eq!(s.doc, before); + } + s.execute("cam.operation.order", &json!({"program":1,"operations":[2,1]})).unwrap(); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.manufacturing.programs[0].operations, vec![1, 2]); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], false); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc.manufacturing.programs[0].operations, vec![2, 1]); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true); + let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap(); + let mut reopened = Session::new(doc); + let id = reopened.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + let state = jobs::job(&reopened, id).unwrap().state().unwrap(); + let path = match state { + jobs::JobState::Generated(path) => Some(path), + _ => None, + } + .unwrap(); + let mut sequence = path.blocks.iter().map(|b| b.operation).collect::>(); + sequence.dedup(); + assert_eq!(sequence, vec![2, 1]); + assert_eq!(reopened.doc.manufacturing.programs[0].operations, vec![2, 1]); + } + + #[test] + fn referenced_tool_delete_rolls_back() { + let mut s = configured(); + let d = s.doc.clone(); + assert!(s.execute("cam.delete", &json!({"kind":"tool","id":1})).is_err()); + assert_eq!(s.doc, d); + } + #[test] + fn missing_body_is_not_guessed() { + let mut s = configured(); + s.doc_mut().manufacturing.setups[0].models[0].body = "missing".into(); + assert!(s.execute("cam.generate", &json!({"program":1})).is_err()); + } + #[test] + fn generate_simulate_post_and_reopen() { + let mut s = configured(); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = r["job"].clone(); + let v = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(v["verification"]["status"], "pass", "{v}"); + let posted = s.execute("cam.post", &json!({"job":id})).unwrap(); + assert!(posted["content"].as_str().unwrap().contains("G21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\n")); + for format in ["toolpath", "setup_sheet", "validation", "stock"] { + assert!(!s.execute("cam.export", &json!({"job":id,"format":format})).unwrap()["content"].as_str().unwrap().is_empty()); + } + let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap(); + let mut reopened = Session::new(doc); + assert!(reopened.execute("cam.post", &json!({"job":id})).is_err()); + assert_eq!(reopened.execute("cam.generate", &json!({"program":1})).unwrap()["state"], "generated"); + s.execute("cam.job.cancel", &json!({"job":id})).unwrap(); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + } + #[test] + fn legacy_document_has_no_manufacturing() { + let mut value: Value = serde_json::from_slice(&crate::io::write_design(&solidcraft_doc::Document::default())).unwrap(); + value["format"] = json!("solidcraft/11"); + value.as_object_mut().unwrap().remove("manufacturing"); + let migrated = crate::io::read_design(value.to_string().as_bytes()).unwrap(); + assert!(migrated.manufacturing.is_empty()); + } + #[test] + fn inconclusive_dimensions_cannot_be_posted() { + let mut s = configured(); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = r["job"].as_u64().unwrap(); + s.execute("cam.simulate", &json!({"job":id})).unwrap(); + let job = jobs::job(&s, id).unwrap(); + if let jobs::JobState::Verified(path, report) = job.state().unwrap() { + let mut report = (*report).clone(); + report.finished_dimensions = cam::verify::Status::Inconclusive; + job.set(jobs::JobState::Verified(path, std::sync::Arc::new(report))); + } + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + } + #[test] + fn drilled_cad_body_generates_verifies_posts_and_reopens() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap(); + s.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true }; + s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap(); + let body = s.model.state().bodies[0].clone(); + let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap(); + let selected = + s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face}]})).unwrap(); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + assert!((selected["diameters"][0].as_f64().unwrap() - 1.).abs() < 1e-8); + let center: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap(); + assert!((center[0][0] - 10.).abs() < 1e-8 && (center[0][1] - 10.).abs() < 1e-8); + s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Drilled plate","setup":1,"operations":[1]}})).unwrap(); + let generated = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = generated["job"].clone(); + let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(verified["verification"]["status"], "pass", "{verified}"); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{verified}"); + assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30")); + let saved = crate::io::write_design(&s.doc); + let mut reopened = Session::new(crate::io::read_design(&saved).unwrap()); + let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap(); + let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap(); + assert_eq!(verified["verification"]["finished_dimensions"], "pass"); + } + #[test] + fn milled_cad_pocket_and_contour_generate_verify_post_and_reopen() { + let mut model = Session::default(); + model.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap(); + model.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":16,"depth":1,"type":"simple"})).unwrap(); + model.execute("sketch.create", &json!({"plane":"XY","name":"Pocket boundary"})).unwrap(); + model.execute("sketch.circle.center", &json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap(); + model.execute("sketch.finish", &json!({})).unwrap(); + let sketch = model.model.state().sketches.last().unwrap().feature; + for name in ["contour", "pocket"] { + let mut s = Session::new((*model.doc).clone()); + let mut input = cam::corpus::fixture(name).unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + input.operations[0].ramp_angle = Some(5_f64.to_radians()); + s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap(); + let selected = s.execute("cam.boundary.from_profile", &json!({"setup":1,"sketch":sketch,"profile":0})).unwrap(); + input.operations[0].source = Some(serde_json::from_value(selected["source"].clone()).unwrap()); + match &mut input.operations[0].geometry { + cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => { + *boundary = serde_json::from_value(selected["boundary"].clone()).unwrap() + } + _ => {} + } + s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap(); + let generated = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = generated["job"].clone(); + let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(verified["verification"]["status"], "pass", "{name}: {verified}"); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}"); + assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30")); + let mut reopened = Session::new(crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap()); + let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap(); + let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap(); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}"); + } + } + #[test] + fn diagnostic_inspection_identifies_posted_operation_and_rejects_stale_results() { + let mut s = configured(); + assert!(s.execute("cam.inspect", &json!({"finding":0})).is_err()); + let origin = crate::geom::Vec3::new(100., 200., 0.); + let mut setup = s.doc.manufacturing.setups[0].clone(); + setup.frame.origin = origin; + setup.stock.min -= origin; + setup.stock.max -= origin; + s.execute("cam.setup.put", &json!({"setup":setup})).unwrap(); + let mut operation = s.doc.manufacturing.operations[0].clone(); + operation.bottom = 3.; + s.execute("cam.operation.put", &json!({"operation":operation})).unwrap(); + let id = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone(); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":0})).is_err()); + let report = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(report["verification"]["status"], "fail"); + let index = report["verification"]["findings"].as_array().unwrap().iter().position(|f| f["source"].is_object()).unwrap(); + let inspected = s.execute("cam.inspect", &json!({"job":id,"finding":index})).unwrap(); + let diagnostic = &inspected["diagnostic"]; + assert_eq!(diagnostic["operation"]["id"], 1); + assert_eq!(diagnostic["playback"]["completed"], diagnostic["finding"]["source"]["block"]); + assert!(diagnostic["gcode_line"].as_str().unwrap().starts_with("G1")); + let at: crate::geom::Vec3 = serde_json::from_value(diagnostic["finding"]["at"].clone()).unwrap(); + assert_eq!(diagnostic["world"], json!(at + origin)); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":-1})).is_err()); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":999999})).is_err()); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + let exported = s.execute("cam.export", &json!({"job":id,"format":"validation"})).unwrap(); + assert!(exported["content"].as_str().unwrap().contains("source")); + s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap(); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":index})).is_err()); + } +} + +#[cfg(test)] +#[path = "cam_holes_tests.rs"] +mod holes_tests; diff --git a/crates/engine/src/cmd/cam_holes_tests.rs b/crates/engine/src/cmd/cam_holes_tests.rs new file mode 100644 index 00000000..f301cd65 --- /dev/null +++ b/crates/engine/src/cmd/cam_holes_tests.rs @@ -0,0 +1,201 @@ +#![allow(clippy::unwrap_used)] +use super::*; +use crate::geom::Vec3; +fn run(s: &mut Session, cmd: &str, value: Value) -> Value { + s.execute(cmd, &value).unwrap() +} +fn linked_profiles() -> (Session, u64) { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6})); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[4,5],"radius":0.5,"id":"a"})); + run(&mut s, "sketch.circle.center", json!({"center":[12,15],"radius":0.5,"id":"b"})); + run(&mut s, "sketch.finish", json!({})); + let state = s.model.state(); + let sketch = state.sketches.last().unwrap(); + let id = sketch.feature; + let a = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"a".to_string())).unwrap(); + let b = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"b".to_string())).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models[0].body = state.bodies[0].name.clone(); + input.setup.frame.origin = Vec3::new(100., -50., 0.); + input.setup.stock.min -= input.setup.frame.origin; + input.setup.stock.max -= input.setup.frame.origin; + run(&mut s, "cam.setup.put", json!({"setup":input.setup})); + let selected = run( + &mut s, + "cam.holes.from_selection", + json!({"setup":1,"selections":[ + {"kind":"profile","sketch":id,"index":b},{"kind":"profile","sketch":id,"index":a} + ]}), + ); + let holes: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap(); + assert_eq!(holes, vec![[-88., 65.], [-96., 55.]]); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + input.operations[0].geometry = cam::Strategy::Drill { holes, peck: Some(0.3), full_diameter: false }; + run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]})); + run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]})); + run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Selected holes","setup":1,"operations":[1]}})); + (s, id) +} +fn centers(s: &mut Session) -> (u64, Vec<[f64; 2]>) { + let generated = run(s, "cam.generate", json!({"program":1})); + assert_eq!(generated["state"], "generated", "{generated}"); + let id = generated["job"].as_u64().unwrap(); + let job = jobs::job(s, id).unwrap(); + let op = &job.input().unwrap().operations[0]; + let holes = match &op.geometry { + cam::Strategy::Drill { holes, .. } => holes.clone(), + _ => vec![], + }; + (id, holes) +} +#[test] +fn hole_selection_follows_profile_edits_frames_undo_and_reopen() { + let (mut s, sketch) = linked_profiles(); + let (old, before) = centers(&mut s); + let saved = crate::io::write_design(&s.doc); + let mut reopened = Session::new(crate::io::read_design(&saved).unwrap()); + assert_eq!(centers(&mut reopened).1, before); + run(&mut s, "sketch.edit", json!({"sketch":sketch})); + run(&mut s, "sketch.move", json!({"entities":["a"],"translate":[2,3]})); + assert_eq!(run(&mut s, "cam.job.status", json!({"job":old}))["stale"], true); + assert!(s.execute("cam.post", &json!({"job":old})).is_err()); + assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(centers(&mut s).1, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]); + // Unassociated drilling preserves manually authored coordinates on reopen. + let mut legacy: Value = serde_json::from_slice(&crate::io::write_design(&s.doc)).unwrap(); + legacy["manufacturing"]["operations"][0].as_object_mut().unwrap().remove("hole_sources"); + let doc = crate::io::read_design(legacy.to_string().as_bytes()).unwrap(); + assert!(doc.manufacturing.operations[0].hole_sources.is_empty()); + assert_eq!(doc.manufacturing.operations[0].geometry, s.doc.manufacturing.operations[0].geometry); +} +#[test] +fn hole_selection_rejects_missing_duplicate_tilted_and_wrong_size_sources() { + let (mut s, sketch) = linked_profiles(); + let pick = json!({"kind":"profile","sketch":sketch,"index":0}); + for picks in [json!([]), json!([pick.clone(), pick.clone()]), json!([{"kind":"profile","sketch":sketch,"index":999}])] { + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":picks})).is_err()); + } + let excessive = vec![pick.clone(); cam::MAX_ITEMS + 1]; + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":excessive})).is_err()); + let mut invalid = s.doc.manufacturing.operations[0].clone(); + invalid.hole_sources.pop(); + assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err()); + let mut invalid = s.doc.manufacturing.operations[0].clone(); + invalid.hole_sources[1] = invalid.hole_sources[0].clone(); + assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err()); + let mut setup = s.doc.manufacturing.setups[0].clone(); + setup.frame = cam::Frame { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z, z: -Vec3::Y }; + run(&mut s, "cam.setup.put", json!({"setup":setup})); + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[pick]})).is_err()); + run(&mut s, "edit.undo", json!({})); + let mut tool = s.doc.manufacturing.tools[0].clone(); + tool.diameter = 0.8; + run(&mut s, "cam.tool.put", json!({"tool":tool})); + let failed = run(&mut s, "cam.generate", json!({"program":1})); + assert_eq!(failed["state"], "failed"); + assert!(failed["error"].as_str().unwrap().contains("diameter")); + assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err()); + run(&mut s, "edit.undo", json!({})); + let mut op = s.doc.manufacturing.operations[0].clone(); + if let cam::HoleSource::Profile { profile } = &mut op.hole_sources[0] { + profile.curves = vec!["missing".into()]; + } + run(&mut s, "cam.operation.put", json!({"operation":op})); + assert!(s.execute("cam.generate", &json!({"program":1})).unwrap_err().to_string().contains("reselect")); +} + +#[test] +fn hole_selection_keeps_exact_occurrence_and_rejects_lost_faces() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Drilled part"})); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6})); + run(&mut s, "solid.hole", json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})); + run(&mut s, "component.activate", json!({"component":0})); + let second = run(&mut s, "occurrence.copy", json!({"component":component["component"],"translate":[30,0,0]}))["occurrence"].as_u64().unwrap(); + let body = s.model.state().bodies[0].clone(); + let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models = vec![cam::BodyRef { body: body.name.clone(), occurrence: vec![second] }]; + input.setup.frame.origin = Vec3::new(20., 0., 0.); + input.setup.stock.min.x += 10.; + input.setup.stock.max.x += 20.; + run(&mut s, "cam.setup.put", json!({"setup":input.setup})); + let selected = run( + &mut s, + "cam.holes.from_selection", + json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face,"occurrence":[second]}]}), + ); + // Viewport labels resolve through the same setup occurrence as explicit command picks. + let state = s.model.state(); + let placed = s.placed_document(); + let label = placed.world_body_name(&state, &body.name, placed.model_body_component(&body), &[second]); + let from_viewport = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":[{"kind":"face","body":label,"index":face}]})); + assert_eq!(from_viewport, selected); + assert!(crate::kernel::hole_axis(&body.body, face, f64::NAN).is_err()); + assert!(crate::kernel::hole_axis(&body.body, usize::MAX, 0.0005).is_err()); + let plane = (0..body.body.face_count()) + .find(|i| matches!(crate::kernel::face_surface(&body.body, *i), Some(crate::kernel::FaceSurface::Plane { .. }))) + .unwrap(); + assert!( + s.execute( + "cam.holes.from_selection", + &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":plane,"occurrence":[second]}]}) + ) + .is_err() + ); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]})); + run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]})); + run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Placed holes","setup":1,"operations":[1]}})); + let (job, before) = centers(&mut s); + assert!((before[0][0] - 20.).abs() < 1e-8 && (before[0][1] - 10.).abs() < 1e-8); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + assert_eq!(run(&mut s, "cam.job.status", json!({"job":job}))["stale"], true); + let moved = centers(&mut s).1; + assert!((moved[0][0] - 25.).abs() < 1e-8); + let mut op = s.doc.manufacturing.operations[0].clone(); + if let cam::HoleSource::Face { name, .. } = &mut op.hole_sources[0] { + *name = "missing:face".into(); + } + run(&mut s, "cam.operation.put", json!({"operation":op})); + let failed = run(&mut s, "cam.generate", json!({"program":1})); + assert_eq!(failed["state"], "failed"); + assert!(failed["error"].as_str().unwrap().contains("reselect")); + assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err()); +} + +#[test] +fn hole_selection_combines_seam_faces_but_rejects_exterior_cylinders() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let body = s.model.state().bodies[0].clone(); + let mut setup = cam::corpus::fixture("drill").unwrap().setup; + setup.models[0].body = body.name.clone(); + run(&mut s, "cam.setup.put", json!({"setup":setup})); + let mut picks = vec![]; + for index in 0..body.body.face_count() { + if let Ok((origin, _, _)) = crate::kernel::hole_axis(&body.body, index, 0.0005) { + if (origin.x - 68.).abs() < 1e-8 { + picks.push(json!({"kind":"face","body":body.name,"index":index})); + } + } else if matches!(crate::kernel::face_surface(&body.body, index), Some(crate::kernel::FaceSurface::Cylinder { .. })) { + assert!( + s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":index}]})).is_err() + ); + } + } + assert!(picks.len() >= 2, "the sample must exercise multiple seam faces on one drilled wall"); + let mut duplicate = picks.clone(); + duplicate.push(picks[1].clone()); + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":duplicate})).is_err()); + let result = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":picks})); + let centers: Vec<[f64; 2]> = serde_json::from_value(result["holes"].clone()).unwrap(); + assert_eq!(centers.len(), 1); + assert!((centers[0][0] - 68.).abs() < 1e-8 && (centers[0][1] - 25.).abs() < 1e-8); + assert_eq!(result["sources"].as_array().unwrap().len(), 1); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7380d9d..f21270fa 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ macro_rules! planned { } mod browser; +mod cam; mod clipboard; mod compare; mod component; @@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); + v.extend(cam::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); v.extend(parts::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6b60a557..19700f0c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,8 @@ pub mod crash; pub mod frames; pub mod fuzz; pub mod licences; +pub mod manufacturing; +pub use solidcraft_cam as cam; pub mod occurrences; pub mod params; pub mod product_scene; @@ -252,6 +254,8 @@ pub struct Session { /// Host-owned viewport metadata, never saved or included in history. render_viewport: Option>, render_jobs: render_jobs::Jobs, + /// Derived, immutable CAM jobs; not persisted or copied into undo snapshots. + pub cam_jobs: manufacturing::Jobs, /// The visibility the design file holds (what `is_dirty` compares with). saved_visibility: Visibility, /// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of @@ -377,6 +381,7 @@ impl Session { camera: None, render_viewport: None, render_jobs: Default::default(), + cam_jobs: Default::default(), #[cfg(test)] evaluations: 0, crash_journal: false, @@ -844,6 +849,7 @@ impl Session { camera: self.camera, render_viewport: self.render_viewport.clone(), render_jobs: self.render_jobs.scratch(), + cam_jobs: Default::default(), saved_visibility: self.saved_visibility.clone(), #[cfg(test)] evaluations: 0, diff --git a/crates/engine/src/manufacturing.rs b/crates/engine/src/manufacturing.rs new file mode 100644 index 00000000..65c367aa --- /dev/null +++ b/crates/engine/src/manufacturing.rs @@ -0,0 +1,461 @@ +//! Immutable CAM jobs, independent of the desktop UI and document undo state. +use crate::{EngineError, Result, Session, cam}; +pub mod exports; +pub(crate) mod holes; +mod snapshot; +use std::collections::BTreeMap; +use std::sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +#[derive(Default)] +pub struct Jobs { + pub jobs: BTreeMap>, + pub next: u64, +} +pub struct Job { + pub source_doc: Arc, + pub source_moves: BTreeMap, + input: Arc>, + snapshot: Mutex>, + stock: Arc, String>>>>, + pub(crate) export: Arc>, + pub cancelled: Arc, + #[cfg(target_arch = "wasm32")] + pending: Mutex>>>>>, + pub progress: Arc, + pub result: Arc>, +} +#[derive(Clone)] +pub enum JobState { + Preparing, + Running, + Generated(Arc), + Verifying(Arc), + Verified(Arc, Arc), + Failed(String), + Cancelled, +} +impl Job { + /// Display geometry is a derived cache. It never substitutes for a verification report. + pub fn stock_mesh(&self) -> Result>> { + if !matches!(self.state()?, JobState::Verified(..)) { + return Ok(None); + } + match self.stock.lock().map_err(|_| EngineError::Other("CAM stock display unavailable".into()))?.as_ref() { + Some(Ok(mesh)) => Ok(Some(mesh.clone())), + Some(Err(e)) => Err(EngineError::Other(e.clone())), + None => Ok(None), + } + } + pub fn input(&self) -> Result<&cam::JobInput> { + self.input.get().ok_or_else(|| EngineError::Other("CAM input is still being prepared".into())) + } + pub fn state(&self) -> Result { + self.result.lock().map(|s| s.clone()).map_err(|_| EngineError::Other("CAM worker state unavailable".into())) + } + pub fn set(&self, state: JobState) { + if let Ok(mut s) = self.result.lock() { + *s = if self.cancelled.load(Ordering::Relaxed) { JobState::Cancelled } else { state }; + } + } +} +pub fn error(e: cam::Error) -> EngineError { + EngineError::Other(e.to_string()) +} +pub fn input(s: &Session, program: u64) -> Result { + snapshot::ready(snapshot::Snapshot::capture(s, program)?.build(&|| false, &|_| {}, &mut || std::future::ready(()))) +} +pub fn profile_boundary(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result { + snapshot::ready(snapshot::Profile::capture(s, setup, source, tolerance)?.sample(&|| false, &mut || std::future::ready(()))) +} +pub fn start(s: &mut Session, program: u64, background: bool) -> Result { + if s.cam_jobs.jobs.len() >= 16 { + let removable = s.cam_jobs.jobs.iter().find_map(|(id, j)| match j.state() { + Ok(JobState::Preparing | JobState::Running | JobState::Verifying(_)) => None, + Ok(JobState::Generated(_) | JobState::Verified(..)) if exports::busy(j).unwrap_or(true) => None, + _ => Some(*id), + }); + if let Some(id) = removable { + s.cam_jobs.jobs.remove(&id); + } else { + return Err(EngineError::Other("too many running CAM jobs".into())); + } + } + let snapshot = snapshot::Snapshot::capture(s, program)?; + let job = Arc::new(Job { + source_doc: s.doc.clone(), + source_moves: s.pending_moves.clone(), + input: Arc::new(OnceLock::new()), + snapshot: Mutex::new(Some(snapshot)), + stock: Arc::new(Mutex::new(None)), + export: Arc::new(Mutex::new(exports::State::Idle)), + cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(target_arch = "wasm32")] + pending: Mutex::new(None), + progress: Arc::new(AtomicUsize::new(0)), + result: Arc::new(Mutex::new(JobState::Preparing)), + }); + s.cam_jobs.next = s.cam_jobs.next.checked_add(1).ok_or_else(|| EngineError::Other("CAM job ID exhausted".into()))?; + let id = s.cam_jobs.next; + s.cam_jobs.jobs.insert(id, job.clone()); + dispatch(job, background, false)?; + Ok(id) +} +pub fn dispatch(job: Arc, background: bool, verify: bool) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + if background { + let failed = job.clone(); + if let Err(e) = std::thread::Builder::new().name("cam".into()).spawn(move || run(job, verify)) { + failed.set(JobState::Failed(e.to_string())); + return Err(EngineError::Other(e.to_string())); + } + return Ok(()); + } + #[cfg(target_arch = "wasm32")] + if background { + let future = task(&job, verify)?; + *job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future)); + return Ok(()); + } + run(job, verify); + Ok(()) +} +// Capture owned task components instead of Job itself: a browser's pending +// future must not keep its owning Job alive through an Arc cycle. +fn task(job: &Job, verify: bool) -> Result> + use<>> { + let input = job.input.clone(); + let cancelled = job.cancelled.clone(); + let progress = job.progress.clone(); + let state = job.result.clone(); + let stock = job.stock.clone(); + let path = if verify { + match job.state()? { + JobState::Verifying(path) => Some(path), + _ => return Err(EngineError::Other("generate a path before simulation".into())), + } + } else { + None + }; + let snapshot = if verify { None } else { job.snapshot.lock().map_err(|_| EngineError::Other("CAM snapshot unavailable".into()))?.take() }; + Ok(async move { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let Some(path) = path { + let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?; + let parsed = cam::nc::round_trip_cooperative(&path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + let report = + cam::verify::simulate_cooperative(input, &parsed, Default::default(), || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let display = stock_display(&report, input.setup.frame, input.setup.stock.min.z, &cancelled).await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let Ok(mut cache) = stock.lock() { + *cache = Some(display.map(Arc::new).map_err(|e| e.to_string())); + } + Ok(JobState::Verified(path, Arc::new(report))) + } else { + let snapshot = snapshot.ok_or_else(|| EngineError::Other("CAM snapshot already consumed".into()))?; + let prepared = snapshot + .build( + &|| cancelled.load(Ordering::Relaxed), + &|n| { + progress.store(n, Ordering::Relaxed); + }, + &mut YieldOnce::default, + ) + .await?; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + input.set(prepared).map_err(|_| EngineError::Other("CAM snapshot already prepared".into()))?; + if let Ok(mut state) = state.lock() { + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + *state = JobState::Running; + } + let mut generator = + cam::generate::Generator::new(input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?.clone()) + .map_err(error)?; + loop { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let done = generator.step().map_err(error)?; + progress.store(20 + (generator.progress() * 80.) as usize, Ordering::Relaxed); + if done { + break; + } + } + Ok(JobState::Generated(Arc::new(generator.finish().map_err(error)?))) + } + }) +} +async fn stock_display(report: &cam::verify::Report, frame: cam::Frame, bottom: f64, cancelled: &AtomicBool) -> Result { + let triangles = + cam::verify::stock_triangles_cooperative(report, bottom, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)?; + let mut mesh = crate::geom::Mesh::default(); + for (i, triangle) in triangles.into_iter().enumerate() { + if i.is_multiple_of(256) { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Err(EngineError::Other("CAM stock display cancelled".into())); + } + } + let triangle = triangle.map(|p| frame.world(p)); + let normal = (triangle[1] - triangle[0]) + .cross(triangle[2] - triangle[0]) + .normalized() + .ok_or_else(|| EngineError::Other("invalid stock display triangle".into()))?; + let base = mesh.positions.len() as u32; + mesh.positions.extend(triangle); + mesh.normals.extend([normal; 3]); + mesh.triangles.push([base, base + 1, base + 2]); + } + Ok(mesh) +} +fn run(job: Arc, verify: bool) { + match task(&job, verify) { + Ok(future) => run_future(job, future, true), + Err(e) => job.set(JobState::Failed(e.to_string())), + } +} +fn run_future(job: Arc, future: impl std::future::Future>, publish_state: bool) { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result { + let mut future = std::pin::pin!(future); + loop { + if job.cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let std::task::Poll::Ready(result) = + std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop())) + { + return result; + } + } + })); + let state = match outcome { + Ok(Ok(state)) => state, + Ok(Err(e)) => JobState::Failed(e.to_string()), + Err(_) => JobState::Failed("CAM worker failed; no result published".into()), + }; + if publish_state { + job.set(state); + } else if let JobState::Failed(error) = state + && let Ok(mut export) = job.export.lock() + { + *export = exports::State::Failed(error); + } +} +pub fn job(s: &Session, id: u64) -> Result> { + s.cam_jobs.jobs.get(&id).cloned().ok_or_else(|| EngineError::Other("CAM job missing; regenerate after reopening".into())) +} +pub fn current(s: &Session, job: &Job) -> Result<()> { + if (!Arc::ptr_eq(&s.doc, &job.source_doc) && *s.doc != *job.source_doc) || s.pending_moves != job.source_moves { + Err(EngineError::Other("CAM result is stale; regenerate after editing the design, tools or setup".into())) + } else { + Ok(()) + } +} + +/// Resume one browser job checkpoint. Returns true while more work is pending. +/// The host controls its frame budget; status queries can still advance one step. +#[cfg(target_arch = "wasm32")] +pub fn advance(job: &Job) -> bool { + let Ok(mut pending) = job.pending.lock() else { return false }; + if job.cancelled.load(Ordering::Relaxed) { + *pending = None; + job.set(JobState::Cancelled); + return false; + } + let Some(future) = pending.as_mut() else { return false }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) + })); + let state = match result { + Ok(std::task::Poll::Pending) => return true, + Ok(std::task::Poll::Ready(Ok(state))) => state, + Ok(std::task::Poll::Ready(Err(error))) => JobState::Failed(error.to_string()), + Err(_) => JobState::Failed("CAM worker failed; no result published".into()), + }; + *pending = None; + job.progress.store(100, Ordering::Relaxed); + job.set(state); + false +} +#[derive(Default)] +struct YieldOnce(bool); +impl std::future::Future for YieldOnce { + type Output = (); + fn poll(mut self: std::pin::Pin<&mut Self>, _: &mut std::task::Context<'_>) -> std::task::Poll<()> { + if self.0 { + std::task::Poll::Ready(()) + } else { + self.0 = true; + std::task::Poll::Pending + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + fn captured() -> (Session, Arc) { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":5})).unwrap(); + let mut input = cam::corpus::fixture("face").unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + let m = &mut s.doc_mut().manufacturing; + m.setups.push(input.setup); + m.tools = input.tools; + m.operations = input.operations; + m.programs.push(cam::Program { id: 1, name: "Test".into(), setup: 1, operations: vec![1] }); + let job = Arc::new(Job { + source_doc: s.doc.clone(), + source_moves: s.pending_moves.clone(), + input: Arc::new(OnceLock::new()), + snapshot: Mutex::new(Some(snapshot::Snapshot::capture(&s, 1).unwrap())), + stock: Arc::new(Mutex::new(None)), + export: Arc::new(Mutex::new(exports::State::Idle)), + cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(target_arch = "wasm32")] + pending: Mutex::new(None), + progress: Arc::new(AtomicUsize::new(0)), + result: Arc::new(Mutex::new(JobState::Preparing)), + }); + (s, job) + } + #[test] + fn completed_export_worker_cannot_restore_state_after_result_collection() { + let (_, job) = captured(); + let ready = Arc::new(std::sync::Barrier::new(2)); + let collected = Arc::new(std::sync::Barrier::new(2)); + let worker_job = job.clone(); + let worker_ready = ready.clone(); + let worker_collected = collected.clone(); + let worker = std::thread::spawn(move || { + let result = worker_job.export.clone(); + run_future( + worker_job, + async move { + *result.lock().unwrap() = exports::State::Ready { content: "complete".into(), destination: None }; + worker_ready.wait(); + worker_collected.wait(); + Ok(JobState::Preparing) + }, + false, + ); + }); + ready.wait(); + assert_eq!(exports::take(&job).unwrap().0, "complete"); + job.set(JobState::Running); + collected.wait(); + worker.join().unwrap(); + assert!(matches!(job.state().unwrap(), JobState::Running)); + } + + #[test] + fn snapshot_is_immutable_and_preparation_is_deferred() { + let (mut s, job) = captured(); + let expected = input(&s, 1).unwrap(); + let mut future = std::pin::pin!(task(&job, false).unwrap()); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + assert!(job.input().is_err()); + s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap(); + s.doc_mut().manufacturing.operations[0].feed = 900.; + let mut polls = 0; + let mut previous = 0; + let state = loop { + match future.as_mut().poll(&mut cx) { + Poll::Pending => { + polls += 1; + let progress = job.progress.load(Ordering::Relaxed); + assert!(progress >= previous); + previous = progress; + } + Poll::Ready(result) => break result.unwrap(), + } + }; + assert!(polls > 2); + assert!(matches!(state, JobState::Generated(_))); + assert_eq!(serde_json::to_value(job.input().unwrap()).unwrap(), serde_json::to_value(&expected).unwrap()); + assert!(current(&s, &job).is_err()); + } + #[test] + fn preparation_cancellation_never_publishes_or_revives_job() { + let (_, job) = captured(); + let mut future = std::pin::pin!(task(&job, false).unwrap()); + let mut cx = Context::from_waker(Waker::noop()); + for _ in 0..2 { + assert!(future.as_mut().poll(&mut cx).is_pending()); + } + job.cancelled.store(true, Ordering::Relaxed); + job.set(JobState::Cancelled); + let result = loop { + if let Poll::Ready(result) = future.as_mut().poll(&mut cx) { + break result; + } + }; + assert!(result.is_err() || matches!(result, Ok(JobState::Cancelled))); + assert!(job.input().is_err()); + job.set(JobState::Running); + assert!(matches!(job.state().unwrap(), JobState::Cancelled)); + } + #[test] + fn native_worker_prepares_generates_and_releases_its_snapshot() { + let (_, job) = captured(); + let worker = job.clone(); + std::thread::spawn(move || run(worker, false)).join().unwrap(); + assert!(matches!(job.state().unwrap(), JobState::Generated(_))); + assert!(job.input().is_ok()); + assert!(job.snapshot.lock().unwrap().is_none()); + assert_eq!(job.progress.load(Ordering::Relaxed), 100); + } + + #[test] + fn cam_snapshot_uses_fixed_tolerance_for_large_curved_geometry() { + let mut session = Session::default(); + session.execute("solid.cylinder", &json!({"radius":5000,"height":6})).unwrap(); + let mut recipe = cam::corpus::fixture("face").unwrap(); + recipe.setup.models[0].body = session.model.state().bodies[0].name.clone(); + recipe.setup.stock.min = crate::geom::Vec3::new(-5000., -5000., 0.); + recipe.setup.stock.max = crate::geom::Vec3::new(5000., 5000., 7.); + let manufacturing = &mut session.doc_mut().manufacturing; + manufacturing.setups.push(recipe.setup); + manufacturing.tools = recipe.tools; + manufacturing.operations = recipe.operations; + manufacturing.programs.push(cam::Program { id: 1, name: "Large cylinder".into(), setup: 1, operations: vec![1] }); + let prepared = input(&session, 1).unwrap(); + let mut checked = 0; + for triangle in &prepared.geometry.target { + if triangle.iter().any(|p| p.z < 1.) && triangle.iter().any(|p| p.z > 5.) { + for i in 0..3 { + let midpoint = (triangle[i] + triangle[(i + 1) % 3]) * 0.5; + assert!((5000. - midpoint.x.hypot(midpoint.y)).abs() <= cam::VERIFY_ERROR / 2.); + checked += 1; + } + } + } + assert!(checked > 1000); + } +} diff --git a/crates/engine/src/manufacturing/exports.rs b/crates/engine/src/manufacturing/exports.rs new file mode 100644 index 00000000..b1c69052 --- /dev/null +++ b/crates/engine/src/manufacturing/exports.rs @@ -0,0 +1,119 @@ +//! Artifact jobs retain immutable source data; only a fresh result command may publish output. +use super::*; + +#[derive(Default)] +pub(crate) enum State { + #[default] + Idle, + Running, + Ready { + content: String, + destination: Option, + }, + Failed(String), +} +pub fn status(job: &Job) -> Result { + if job.cancelled.load(Ordering::Relaxed) { + return Ok(serde_json::json!({"state":"cancelled"})); + } + let state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + Ok(match &*state { + State::Idle => serde_json::json!({"state":"idle"}), + State::Running => serde_json::json!({"state":"running"}), + State::Ready { content, .. } => serde_json::json!({"state":"ready","bytes":content.len()}), + State::Failed(error) => serde_json::json!({"state":"failed","error":error}), + }) +} +pub fn busy(job: &Job) -> Result { + Ok(matches!(*job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?, State::Running | State::Ready { .. })) +} +pub fn take(job: &Job) -> Result<(String, Option)> { + if job.cancelled.load(Ordering::Relaxed) { + return Err(EngineError::Other("CAM export cancelled".into())); + } + let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + if !matches!(*state, State::Ready { .. }) { + return Err(EngineError::Other("CAM export is not ready".into())); + } + match std::mem::take(&mut *state) { + State::Ready { content, destination } => Ok((content, destination)), + _ => Err(EngineError::Other("CAM export is not ready".into())), + } +} +pub fn start(job: Arc, format: String, destination: Option) -> Result<()> { + if !matches!(format.as_str(), "gcode" | "toolpath" | "validation" | "stock" | "setup_sheet") { + return Err(EngineError::Other("unknown CAM artifact format".into())); + } + let original = job.state()?; + if !matches!(original, JobState::Generated(_) | JobState::Verified(..)) { + return Err(EngineError::Other("no generated result".into())); + } + { + let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + if matches!(*state, State::Running | State::Ready { .. }) { + return Err(EngineError::Other("finish or cancel the current export first".into())); + } + *state = State::Running; + } + #[cfg(not(target_arch = "wasm32"))] + { + let failed = job.clone(); + if let Err(e) = std::thread::Builder::new().name("cam-export".into()).spawn(move || { + let future = task(&job, original, format, destination); + // A ready result may already have been collected and another job started. + // Completing an export must never restore its old generation/verification state. + run_future(job, future, false); + }) { + if let Ok(mut state) = failed.export.lock() { + *state = State::Failed(e.to_string()); + } + return Err(EngineError::Other(e.to_string())); + } + } + #[cfg(target_arch = "wasm32")] + { + let future = task(&job, original, format, destination); + *job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future)); + } + Ok(()) +} +fn task(job: &Job, original: JobState, format: String, destination: Option) -> impl std::future::Future> + use<> { + let input = job.input.clone(); + let cancelled = job.cancelled.clone(); + let result = job.export.clone(); + async move { + YieldOnce::default().await; + let produce = async { + let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?; + let (path, report) = match &original { + JobState::Generated(path) => (path, None), + JobState::Verified(path, report) => (path, Some(report.as_ref())), + _ => return Err(EngineError::Other("no generated result".into())), + }; + if format == "gcode" { + let report = report.ok_or_else(|| EngineError::Other("simulate before posting".into()))?; + if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete { + return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into())); + } + cam::nc::round_trip_cooperative(path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + cam::nc::post_cooperative(path, &input.setup, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error) + } else { + crate::io::cam::artifact_cooperative(input, path, report, &format, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(|e| EngineError::Other(e.to_string())) + } + } + .await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let mut state = result.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + *state = match produce { + Ok(content) => State::Ready { content, destination }, + Err(error) => State::Failed(error.to_string()), + }; + Ok(original) + } +} diff --git a/crates/engine/src/manufacturing/holes.rs b/crates/engine/src/manufacturing/holes.rs new file mode 100644 index 00000000..b1e24caf --- /dev/null +++ b/crates/engine/src/manufacturing/holes.rs @@ -0,0 +1,179 @@ +//! Explicit hole picks and immutable resolution of their persistent identities. +use super::snapshot::{PlacedBody, Profile}; +use crate::{ + EngineError, Result, Session, cam, + geom::{Seg2, Vec3}, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_doc::apply_point; + +fn bad(message: &str) -> EngineError { + EngineError::Other(message.into()) +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum Pick { + Face { + body: String, + index: usize, + #[serde(default)] + occurrence: Option>, + }, + Profile { + sketch: u64, + index: usize, + #[serde(default)] + occurrence: Vec, + }, +} +#[derive(Serialize)] +pub(crate) struct Selected { + pub center: [f64; 2], + pub diameter: f64, +} +pub(super) enum Hole { + Face { body: Box, name: String }, + Profile(Profile), +} +impl Hole { + pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::HoleSource, tolerance: f64) -> Result { + source.validate().map_err(super::error)?; + match source { + cam::HoleSource::Face { target, name } => { + if !setup.models.contains(target) { + return Err(bad("select a hole on one of this setup's model occurrences")); + } + Ok(Self::Face { body: Box::new(PlacedBody::capture(s, target)?), name: name.clone() }) + } + cam::HoleSource::Profile { profile } => Ok(Self::Profile(Profile::capture(s, setup, profile, tolerance)?)), + } + } + pub(super) fn resolve(self, frame: cam::Frame, tolerance: f64) -> Result { + let (origin, axis, diameter) = match self { + Self::Face { body, name } => { + let names = solidcraft_doc::naming::face_names(&body.body); + let mut indices = names.iter().enumerate().filter_map(|(i, n)| (*n == name).then_some(i)); + let index = indices.next().ok_or_else(|| bad("CAM hole face missing; reselect it"))?; + if indices.next().is_some() { + return Err(bad("CAM hole face is ambiguous; reselect it")); + } + let (origin, axis, radius) = crate::kernel::hole_axis(&body.body.body, index, cam::profile_tolerance(tolerance))?; + let point = |p| frame.local(apply_point(&body.placement, p)); + let center = point(origin); + let x = axis.any_perp(); + let y = axis.cross(x); + let dx = point(origin + x) - center; + let dy = point(origin + y) - center; + if (dx.len() - dy.len()).abs() > 1e-9 || dx.dot(dy).abs() > 1e-9 { + return Err(bad("non-circular placed hole; select a rigid occurrence")); + } + (center, point(origin + axis) - center, 2. * radius * dx.len()) + } + Self::Profile(profile) => { + if !profile.region.holes.is_empty() { + return Err(bad("select one circular profile per drill center")); + } + let Some(Seg2::Arc { center, radius, .. }) = profile.region.outer.segs.first() else { + return Err(bad("drilling profile must be circular")); + }; + let (center, radius) = (*center, *radius); + let mut sweep = 0.; + let segments = &profile.region.outer.segs; + for (a, next) in segments.iter().zip(segments.iter().cycle().skip(1)).take(segments.len()) { + let Seg2::Arc { center: c, radius: r, sweep: angle, .. } = a else { + return Err(bad("drilling profile must be circular")); + }; + if c.dist(center) > 1e-9 || (r - radius).abs() > 1e-9 || a.end().dist(next.start()) > 1e-8 { + return Err(bad("drilling profile must be a closed circle")); + } + sweep += angle; + } + if !sweep.is_finite() || (sweep.abs() - std::f64::consts::TAU).abs() > 1e-8 || radius <= 0. { + return Err(bad("drilling profile must be one complete circle")); + } + let (x, y) = (profile.plane.x, profile.plane.y); + if (x.len() - y.len()).abs() > 1e-9 || x.dot(y).abs() > 1e-9 { + return Err(bad("non-circular placed profile; select a rigid occurrence")); + } + (profile.plane.to_world(center), x.cross(y), 2. * radius * x.len()) + } + }; + let axis = axis.normalized().ok_or_else(|| bad("invalid hole axis"))?; + if !origin.is_finite() || !axis.is_finite() || !diameter.is_finite() || diameter <= 0. || axis.cross(Vec3::Z).len() > 1e-9 { + return Err(bad("drilling needs a finite circular hole parallel to setup Z")); + } + Ok(Selected { center: [origin.x, origin.y], diameter }) + } +} + +pub(crate) fn select(s: &Session, setup: &cam::Setup, picks: Vec) -> Result<(Vec, Vec)> { + if picks.is_empty() || picks.len() > cam::MAX_ITEMS { + return Err(bad("select between one and 1000 holes")); + } + let state = s.model.state(); + let doc = s.placed_document(); + let mut sources = Vec::new(); + let mut seen = Vec::new(); + let mut selected = Vec::new(); + for pick in picks { + let source = match pick { + Pick::Face { body, index, occurrence } => { + let target = if let Some(occurrence) = occurrence { + cam::BodyRef { body, occurrence } + } else { + if setup.models.len().saturating_mul(doc.occurrences.len() + state.bodies.len() + 64) > 2_000_000 { + return Err(bad("hole selection work limit; provide a definition body and occurrence path")); + } + // Only these explicit model placements may be selected. Do not expand + // an entire shared assembly merely to interpret a viewport label. + let mut matching = setup.models.iter().filter(|target| { + state.body(&target.body).is_some_and(|model| { + doc.world_body_name(&state, &model.name, doc.model_body_component(model), &target.occurrence) == body + }) + }); + let target = matching.next().ok_or_else(|| bad("selected hole body is not in this setup; reselect its model occurrence"))?; + if matching.next().is_some() { + return Err(bad("selected hole body ambiguous; provide its occurrence path")); + } + target.clone() + }; + let placed = PlacedBody::capture(s, &target)?; + let name = solidcraft_doc::naming::face_names(&placed.body).get(index).cloned().ok_or_else(|| bad("selected hole face missing"))?; + cam::HoleSource::Face { target, name } + } + Pick::Profile { sketch, index, occurrence } => { + let profile = state + .sketches + .iter() + .find(|s| s.feature == sketch) + .and_then(|s| s.profiles.get(index)) + .ok_or_else(|| bad("selected circular profile missing"))?; + cam::HoleSource::Profile { profile: cam::ProfileRef { sketch, curves: profile.curve_ids(), occurrence } } + } + }; + if seen.contains(&source) { + return Err(bad("duplicate hole selection")); + } + seen.push(source.clone()); + let hole = Hole::capture(s, setup, &source, cam::TOLERANCE)?.resolve(setup.frame, cam::TOLERANCE)?; + if let Some((old, prior)) = sources + .iter() + .zip(&selected) + .find(|(_, p): &(_, &Selected)| (p.center[0] - hole.center[0]).hypot(p.center[1] - hole.center[1]) < cam::TOLERANCE) + { + // A viewport pick expands a cylinder across its topological seam. Its + // faces still describe one hole on one explicitly placed body. + if matches!((old, &source), (cam::HoleSource::Face { target: a, .. }, cam::HoleSource::Face { target: b, .. }) if a == b) + && (prior.center[0] - hole.center[0]).hypot(prior.center[1] - hole.center[1]) < 1e-8 + && (prior.diameter - hole.diameter).abs() < 1e-8 + { + continue; + } + return Err(bad("selected hole axes coincide; remove duplicate selections")); + } + sources.push(source); + selected.push(hole); + } + Ok((sources, selected)) +} diff --git a/crates/engine/src/manufacturing/snapshot.rs b/crates/engine/src/manufacturing/snapshot.rs new file mode 100644 index 00000000..0218d856 --- /dev/null +++ b/crates/engine/src/manufacturing/snapshot.rs @@ -0,0 +1,422 @@ +//! Cheap immutable capture on the session thread; curve sampling, tessellation, +//! transforms and hashing happen during preparation on the job's worker. +use crate::{ + EngineError, Result, Session, cam, + geom::{Plane, Region2, Seg2, Vec2, Vec3}, +}; +use solidcraft_doc::{Mat, apply_point}; + +pub(super) struct Snapshot { + setup: cam::Setup, + tools: Vec, + operations: Vec, + profiles: Vec<(usize, Profile)>, + holes: Vec<(usize, Vec)>, + models: Vec, + fixtures: Vec, +} +pub(super) struct PlacedBody { + pub(super) body: solidcraft_doc::ModelBody, + pub(super) placement: Mat, +} +pub(super) struct Profile { + pub(super) region: Region2, + pub(super) plane: Plane, + tolerance: f64, +} +fn error(message: &str) -> EngineError { + EngineError::Other(message.into()) +} +fn cancelled(check: &impl Fn() -> bool) -> Result<()> { + if check() { Err(error("CAM preparation cancelled")) } else { Ok(()) } +} + +impl Snapshot { + pub(super) fn capture(s: &Session, program: u64) -> Result { + s.doc.manufacturing.validate().map_err(super::error)?; + let m = &s.doc.manufacturing; + let p = m.programs.iter().find(|p| p.id == program).ok_or_else(|| error("CAM program not found"))?; + let setup = m.setups.iter().find(|x| x.id == p.setup).cloned().ok_or_else(|| error("CAM setup missing"))?; + let operations = p + .operations + .iter() + .map(|id| m.operations.iter().find(|o| o.id == *id).cloned().ok_or_else(|| error("CAM operation missing"))) + .collect::>>()?; + let mut profiles = Vec::new(); + for (i, o) in operations.iter().enumerate() { + if let Some(source) = &o.source { + profiles.push((i, Profile::capture(s, &setup, source, o.tolerance)?)); + } + } + let mut holes = Vec::new(); + for (index, operation) in operations.iter().enumerate() { + if !operation.hole_sources.is_empty() { + holes.push(( + index, + operation + .hole_sources + .iter() + .map(|source| super::holes::Hole::capture(s, &setup, source, operation.tolerance)) + .collect::>()?, + )); + } + } + let tools = m.tools.iter().filter(|t| operations.iter().any(|o| o.tool == t.id)).cloned().collect::>(); + if tools.iter().map(|t| t.number).collect::>().len() != tools.len() { + return Err(error("CAM tools must have unique tool numbers within a program")); + } + let models = setup.models.iter().map(|r| PlacedBody::capture(s, r)).collect::>()?; + let fixtures = setup.fixtures.iter().map(|r| PlacedBody::capture(s, r)).collect::>()?; + Ok(Self { setup, tools, operations, profiles, holes, models, fixtures }) + } + pub(super) async fn build>( + mut self, + check: &impl Fn() -> bool, + progress: &impl Fn(usize), + yield_control: &mut impl FnMut() -> F, + ) -> Result { + let total = + self.profiles.len() + self.holes.iter().map(|(_, holes)| holes.len()).sum::() + self.models.len() + self.fixtures.len() + 1; + let mut done = 0; + for (index, p) in self.profiles { + cancelled(check)?; + yield_control().await; + let boundary = p.sample(check, yield_control).await?; + let op = self.operations.get_mut(index).ok_or_else(|| error("CAM operation missing from snapshot"))?; + match &mut op.geometry { + cam::Strategy::Contour { boundary: b, .. } | cam::Strategy::Pocket { boundary: b } => *b = boundary, + _ => return Err(error("profile bound to unsupported operation")), + } + done += 1; + progress(done * 20 / total); + } + for (index, holes) in self.holes { + let operation = self.operations.get_mut(index).ok_or_else(|| error("CAM hole operation missing"))?; + let tool = self.tools.iter().find(|t| t.id == operation.tool).ok_or_else(|| error("CAM drill missing"))?; + let mut centers = Vec::new(); + for hole in holes { + cancelled(check)?; + yield_control().await; + let selected = hole.resolve(self.setup.frame, operation.tolerance)?; + if (selected.diameter - tool.diameter).abs() > operation.tolerance { + return Err(error("selected hole diameter differs from the drill; choose a matching tool or unlink the hole")); + } + if centers.iter().any(|p: &[f64; 2]| (p[0] - selected.center[0]).hypot(p[1] - selected.center[1]) <= operation.tolerance) { + return Err(error("selected hole axes coincide; remove duplicate selections")); + } + centers.push(selected.center); + done += 1; + progress(done * 20 / total); + } + let cam::Strategy::Drill { holes, .. } = &mut operation.geometry else { + return Err(error("hole references require drilling")); + }; + *holes = centers; + } + let mut target = Vec::new(); + let mut fixtures = Vec::new(); + let mut fixture_groups = Vec::new(); + for body in self.models { + body.append(self.setup.frame, &mut target, check, yield_control).await?; + done += 1; + progress(done * 20 / total); + } + for body in self.fixtures { + let before = fixtures.len(); + body.append(self.setup.frame, &mut fixtures, check, yield_control).await?; + fixture_groups.push(fixtures.len() - before); + done += 1; + progress(done * 20 / total); + } + if target.len() + fixtures.len() > 200_000 { + return Err(error("CAM mesh exceeds verification budget")); + } + cancelled(check)?; + yield_control().await; + let mut input = cam::JobInput { + setup: self.setup, + tools: self.tools, + operations: self.operations, + geometry: cam::Geometry { target, fixtures, fixture_groups }, + fingerprint: String::new(), + }; + input.validate().map_err(super::error)?; + input.fingerprint = cam::fingerprint_job_cooperative(&input, check, yield_control).await.map_err(super::error)?; + cancelled(check)?; + progress(20); + Ok(input) + } +} +impl PlacedBody { + pub(super) fn capture(s: &Session, r: &cam::BodyRef) -> Result { + let state = s.model.state(); + let candidates: Vec<_> = state.bodies.iter().filter(|b| b.name == r.body).collect(); + if candidates.len() != 1 { + return Err(error(&format!("CAM body `{}` missing or ambiguous; reselect it", r.body))); + } + let b = candidates[0]; + let component = s.doc.model_body_component(b); + let placement = if r.occurrence.is_empty() { + if component != 0 { + return Err(error("CAM component body requires an occurrence path")); + } + solidcraft_doc::IDENTITY + } else { + let p = s.occurrence_placement(&r.occurrence)?; + if p.component != component { + return Err(error("CAM body no longer belongs to selected occurrence")); + } + p.world + }; + let placement = s.doc.model_body_offset(b).map(|offset| solidcraft_doc::mat_mul(&placement, offset)).unwrap_or(placement); + Ok(Self { body: b.clone(), placement }) + } + async fn append>( + self, + frame: cam::Frame, + out: &mut Vec<[Vec3; 3]>, + check: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result<()> { + cancelled(check)?; + yield_control().await; + cancelled(check)?; + let mesh = self.body.body.tessellate_for_verification(cam::VERIFY_ERROR / 2., 200_000usize.saturating_sub(out.len()))?; + if out.len() + mesh.triangles.len() > 200_000 { + return Err(error("CAM mesh exceeds verification budget")); + } + for (i, indices) in mesh.triangles.iter().enumerate() { + if i.is_multiple_of(256) { + cancelled(check)?; + yield_control().await; + } + let triangle = mesh.tri(indices).ok_or_else(|| error("CAM mesh contains invalid indices"))?; + out.push(triangle.map(|v| frame.local(apply_point(&self.placement, v)))); + } + Ok(()) + } +} +impl Profile { + pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result { + let state = s.model.state(); + let sketch = state.sketches.iter().find(|x| x.feature == source.sketch).ok_or_else(|| error("CAM sketch missing; reselect the profile"))?; + let mut wanted = source.curves.clone(); + wanted.sort(); + let candidates: Vec<_> = sketch + .profiles + .iter() + .filter(|p| { + let mut ids = p.curve_ids(); + ids.sort(); + ids == wanted + }) + .collect(); + if candidates.len() != 1 { + return Err(error("CAM profile missing or ambiguous; reselect it")); + } + let component = s.doc.feature(source.sketch).map(|f| f.component).ok_or_else(|| error("CAM sketch feature missing"))?; + let transform = if source.occurrence.is_empty() { + if component != 0 { + return Err(error("CAM sketch needs an explicit occurrence path")); + } + solidcraft_doc::IDENTITY + } else { + let p = s.occurrence_placement(&source.occurrence)?; + if p.component != component { + return Err(error("CAM sketch occurrence mismatch")); + } + p.world + }; + let point = |p| setup.frame.local(apply_point(&transform, p)); + let origin = point(sketch.plane.origin); + let plane = + Plane { origin, x: point(sketch.plane.origin + sketch.plane.x) - origin, y: point(sketch.plane.origin + sketch.plane.y) - origin }; + Ok(Self { region: candidates[0].region.clone(), plane, tolerance: cam::profile_tolerance(tolerance) }) + } + fn segments(&self, s: &Seg2) -> Result { + let v = |p: Vec2| self.plane.x * p.x + self.plane.y * p.y; + let m = match s { + Seg2::Line { .. } => 0., + Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.powi(2) * (self.plane.x.len2() + self.plane.y.len2()).sqrt(), + Seg2::Cubic { p0, p1, p2, p3 } => 6. * v(*p0 - *p1 * 2. + *p2).len().max(v(*p1 - *p2 * 2. + *p3).len()), + Seg2::Bezier { n, p } => { + let points = p.get(..=*n as usize).filter(|p| p.len() >= 2).ok_or_else(|| error("invalid CAM Bezier degree"))?; + *n as f64 * (*n as f64 - 1.) * points.windows(3).map(|p| v(p[0] - p[1] * 2. + p[2]).len()).fold(0., f64::max) + } + Seg2::Conic { a, apex, b, w } => { + if !w.is_finite() || *w <= -1. { + return Err(error("singular CAM conic profile")); + } + let u = v((*apex - *a) * *w); + let b = v(*b - *a); + let d = 1_f64.min((1. + w) / 2.); + let n = u.len().max(b.len()); + let n1 = 2. * u.len().max((b - u).len()); + let n2 = 2. * (b - u * 2.).len(); + let d1 = 2. * (w - 1.).abs(); + let d2 = 4. * (w - 1.).abs(); + n2 / d + 2. * n1 * d1 / d.powi(2) + n * d2 / d.powi(2) + 2. * n * d1.powi(2) / d.powi(3) + } + }; + // ||curve''||/(8 n²) bounds the chord error on every parameter interval. + // The reserve covers evaluation/placement rounding, rather than silently + // clamping the segment count or increasing the requested tolerance. + let size = |p: Vec2| self.plane.x.len() * p.x.abs() + self.plane.y.len() * p.y.abs(); + let magnitude = match s { + Seg2::Line { a, b } => size(*a) + size(*b), + Seg2::Arc { center, radius, start, sweep } => { + size(*center) + radius.abs() * (1. + start.abs() + sweep.abs()) * (self.plane.x.len() + self.plane.y.len()) + } + Seg2::Cubic { p0, p1, p2, p3 } => size(*p0) + size(*p1) + size(*p2) + size(*p3), + Seg2::Bezier { n, p } => p.get(..=*n as usize).ok_or_else(|| error("invalid CAM Bezier degree"))?.iter().map(|p| size(*p)).sum(), + Seg2::Conic { a, apex, b, w } => (size(*a) + w.abs() * size(*apex) + size(*b)) / 1_f64.min((1. + w) / 2.), + }; + let guard = 256. * f64::EPSILON * (1. + self.plane.origin.len() + magnitude + m.abs()); + let budget = self.tolerance * 15. / 16. - guard; + if !m.is_finite() || !budget.is_finite() || budget <= 0. { + return Err(error("CAM profile precision cannot meet the requested tolerance")); + } + let count = ((m * (1. + 256. * f64::EPSILON) / (8. * budget)).sqrt().ceil()).max(1.); + if !count.is_finite() || count > 10_000. { + return Err(error("CAM profile exceeds 10000 vertices at the required tolerance")); + } + Ok(count as usize) + } + pub(super) async fn sample>( + &self, + check: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result { + if !self.plane.origin.is_finite() || !self.plane.x.is_finite() || !self.plane.y.is_finite() || self.plane.normal().len() < 1e-12 { + return Err(error("degenerate CAM profile placement")); + } + if self.plane.x.z.abs() > 1e-10 * self.plane.x.len() || self.plane.y.z.abs() > 1e-10 * self.plane.y.len() { + return Err(error("CAM profile must be perpendicular to setup Z")); + } + let mut rings = Vec::new(); + let mut total = 0; + for ring in std::iter::once(&self.region.outer).chain(&self.region.holes) { + let mut points = Vec::new(); + for (a, b) in ring.segs.iter().zip(ring.segs.iter().cycle().skip(1)).take(ring.segs.len()) { + let gap = self.plane.dir_to_world(a.end() - b.start()).len(); + if !gap.is_finite() || gap > self.tolerance / 16. { + return Err(error("disconnected CAM profile segments")); + } + } + for curve in &ring.segs { + cancelled(check)?; + yield_control().await; + let count = self.segments(curve)?; + total += count; + if total > 10_000 { + return Err(error("CAM profile exceeds 10000 vertices at the required tolerance")); + } + for i in 0..count { + if i.is_multiple_of(256) { + cancelled(check)?; + yield_control().await; + } + let p = self.plane.to_world(curve.point_at(i as f64 / count as f64)); + if !p.is_finite() { + return Err(error("non-finite CAM profile point")); + } + points.push([p.x, p.y]); + } + } + if points.first() == points.last() { + points.pop(); + } + rings.push(points); + } + let mut rings = rings.into_iter(); + let boundary = cam::Boundary { outer: rings.next().ok_or_else(|| error("empty CAM profile"))?, islands: rings.collect() }; + boundary.validate().map_err(super::error)?; + Ok(boundary) + } +} + +pub(super) fn ready(future: impl std::future::Future>) -> Result { + use std::task::{Context, Poll, Waker}; + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result, + Poll::Pending => Err(error("synchronous CAM preparation unexpectedly yielded")), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::geom::Loop2; + fn profile() -> Profile { + Profile { region: Region2 { outer: Loop2::circle(Vec2::ZERO, 8.), holes: vec![] }, plane: Plane::XY, tolerance: cam::profile_tolerance(0.01) } + } + #[test] + fn bounded_sampler_meets_transformed_curve_chord_budget() { + let mut p = profile(); + p.plane.x = Vec3::new(3., 1., 0.); + p.plane.y = Vec3::new(-1., 0.5, 0.); + p.plane.origin = Vec3::new(100., -120., 2.); + let a = Vec2::new(2., -1.); + let b = Vec2::new(-2., 3.); + let c = Vec2::new(4., 8.); + let mut curves = vec![ + Seg2::Line { a, b }, + Seg2::Arc { center: a, radius: 8., start: 0.2, sweep: -5. }, + Seg2::Cubic { p0: a, p1: c, p2: b, p3: Vec2::ZERO }, + Seg2::Bezier { n: 7, p: [a, b, c, a, b, c, a, b, c, a] }, + Seg2::Bezier { n: 9, p: [a, b, c, a, b, c, a, b, c, a] }, + ]; + for w in [-0.5, 0., 0.7, 1., 5.] { + curves.push(Seg2::Conic { a, apex: c, b, w }); + } + for curve in curves { + let n = p.segments(&curve).unwrap(); + for i in 0..n { + let a = p.plane.to_world(curve.point_at(i as f64 / n as f64)); + let b = p.plane.to_world(curve.point_at((i + 1) as f64 / n as f64)); + for k in 1..8 { + let fraction = k as f64 / 8.; + let exact = p.plane.to_world(curve.point_at((i as f64 + fraction) / n as f64)); + assert!(exact.dist(a + (b - a) * fraction) <= p.tolerance, "{curve:?}"); + } + } + } + } + #[test] + fn precision_and_vertex_limits_fail_without_relaxing_tolerance() { + let mut p = profile(); + let circle = Seg2::Arc { center: Vec2::ZERO, radius: 1e6, start: 0., sweep: std::f64::consts::TAU }; + assert!(p.segments(&circle).is_err()); + p.plane.origin = Vec3::new(1e15, 0., 0.); + assert!(p.segments(&Seg2::Line { a: Vec2::ZERO, b: Vec2::X }).is_err()); + let p = profile(); + assert!(p.segments(&Seg2::Bezier { n: 255, p: Default::default() }).is_err()); + assert!(p.segments(&Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1. }).is_err()); + assert!(p.segments(&Seg2::Line { a: Vec2::new(f64::NAN, 0.), b: Vec2::ZERO }).is_err()); + } + #[test] + fn profile_sampling_yields_and_honours_cancellation_and_planarity() { + use std::{ + future::Future, + sync::atomic::{AtomicBool, Ordering}, + task::{Context, Poll, Waker}, + }; + let p = profile(); + let cancel = AtomicBool::new(false); + let check = || cancel.load(Ordering::Relaxed); + let mut yields = super::super::YieldOnce::default; + let mut future = std::pin::pin!(p.sample(&check, &mut yields)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + cancel.store(true, Ordering::Relaxed); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + let mut p = profile(); + p.plane = Plane::XZ; + assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err()); + p.plane = Plane::XY; + p.region.outer.segs.pop(); + assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err()); + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 7afd3eeb..b6dd52fc 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-kernel = { workspace = true } solidcraft-doc = { workspace = true } diff --git a/crates/io/src/cam.rs b/crates/io/src/cam.rs new file mode 100644 index 00000000..4f42cf87 --- /dev/null +++ b/crates/io/src/cam.rs @@ -0,0 +1,62 @@ +//! CAM artifact serialization. The engine owns freshness and posting gates. +mod incremental; +pub use incremental::artifact_cooperative; + +#[cfg(test)] +use crate::IoError; +use crate::Result; +use solidcraft_cam::{JobInput, Toolpath, verify::Report}; +pub fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} +/// Synchronous compatibility wrapper; interactive clients use the cooperative entry point. +pub fn artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result { + let mut future = std::pin::pin!(artifact_cooperative(input, path, report, format, || false, || std::future::ready(()))); + loop { + if let std::task::Poll::Ready(result) = + std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop())) + { + return result; + } + } +} + +// Original serializer retained only as an independent schema/escaping oracle in tests. +#[cfg(test)] +fn reference_artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result { + let json = |v: serde_json::Value| serde_json::to_string_pretty(&v).map_err(|e| IoError::Invalid(e.to_string())); + match format { + "toolpath" => json(serde_json::json!({"version":solidcraft_cam::VERSION,"input":input,"toolpath":path})), + "validation" => json( + serde_json::to_value(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?).map_err(|e| IoError::Invalid(e.to_string()))?, + ), + "stock" => solidcraft_cam::verify::stock_stl(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?, input.setup.stock.min.z) + .map_err(|e| IoError::Invalid(e.to_string())), + "setup_sheet" => { + let mut html = format!( + "{}

{}

Reference three-axis milling — not a qualified machine post.

Fingerprint: {}

Setup

{}

Operations

", + escape(&input.setup.name), + escape(&input.setup.name), + escape(&path.fingerprint), + escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?) + ); + for o in &input.operations { + html.push_str(&format!( + "", + escape(&o.name), + o.tool, + o.feed, + o.spindle, + o.bottom + )); + } + html.push_str("
NameToolFeed mm/minSpindle rpmBottom mm
{}{}{}{}{}

Tools

");
+            html.push_str(&escape(&serde_json::to_string_pretty(&input.tools).map_err(|e| IoError::Invalid(e.to_string()))?));
+            html.push_str("

Operation settings and selected geometry

");
+            html.push_str(&escape(&serde_json::to_string_pretty(&input.operations).map_err(|e| IoError::Invalid(e.to_string()))?));
+            html.push_str("
"); + Ok(html) + } + _ => Err(IoError::Invalid("unknown CAM artifact format".into())), + } +} diff --git a/crates/io/src/cam/incremental.rs b/crates/io/src/cam/incremental.rs new file mode 100644 index 00000000..7401e8ce --- /dev/null +++ b/crates/io/src/cam/incremental.rs @@ -0,0 +1,276 @@ +//! Bounded artifact serialization. Large arrays never become an intermediate JSON Value. +use super::{JobInput, Report, Toolpath, escape}; +use crate::{IoError, Result}; +use serde::Serialize; +use std::future::Future; + +const MAX_BYTES: usize = 128 * 1024 * 1024; +struct Writer { + out: String, + cancelled: C, + yield_control: Y, +} +impl bool, Y: FnMut() -> F, F: Future> Writer { + async fn checkpoint(&mut self) -> Result<()> { + if (self.cancelled)() { + return Err(IoError::Invalid("CAM export cancelled".into())); + } + if self.out.len() > MAX_BYTES { + return Err(IoError::Invalid("CAM artifact exceeds 128 MiB".into())); + } + (self.yield_control)().await; + if (self.cancelled)() { + return Err(IoError::Invalid("CAM export cancelled".into())); + } + Ok(()) + } + fn value(&mut self, v: &impl Serialize) -> Result<()> { + self.out.push_str(&serde_json::to_string(v).map_err(|e| IoError::Invalid(e.to_string()))?); + Ok(()) + } + async fn array(&mut self, values: &[T], batch: usize) -> Result<()> { + self.out.push('['); + for (i, value) in values.iter().enumerate() { + if i.is_multiple_of(batch) { + self.checkpoint().await?; + } + if i != 0 { + self.out.push(','); + } + self.value(value)?; + } + self.out.push(']'); + Ok(()) + } + async fn html_array(&mut self, values: &[T]) -> Result<()> { + self.out.push('['); + for (i, value) in values.iter().enumerate() { + self.checkpoint().await?; + if i != 0 { + self.out.push(','); + } + self.out.push('\n'); + self.out.push_str(&escape(&serde_json::to_string_pretty(value).map_err(|e| IoError::Invalid(e.to_string()))?)); + } + self.out.push_str("\n]"); + Ok(()) + } +} + +/// Build a complete artifact, yielding between bounded array batches and observing cancellation. +/// JSON field values match `artifact`; whitespace/object order may differ. No partial output escapes. +pub async fn artifact_cooperative>( + input: &JobInput, + path: &Toolpath, + report: Option<&Report>, + format: &str, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + if format == "stock" { + return solidcraft_cam::verify::stock_stl_cooperative( + report.ok_or_else(|| IoError::Invalid("simulate first".into()))?, + input.setup.stock.min.z, + cancelled, + yield_control, + ) + .await + .map_err(|e| IoError::Invalid(e.to_string())); + } + let mut w = Writer { out: String::new(), cancelled, yield_control }; + w.checkpoint().await?; + match format { + "toolpath" => { + w.out.push_str("{\"version\":"); + w.value(&solidcraft_cam::VERSION)?; + w.out.push_str(",\"input\":{\"setup\":"); + w.value(&input.setup)?; + w.out.push_str(",\"tools\":"); + w.array(&input.tools, 1).await?; + w.out.push_str(",\"operations\":"); + w.array(&input.operations, 1).await?; + w.out.push_str(",\"fingerprint\":"); + w.value(&input.fingerprint)?; + w.out.push_str(",\"geometry\":{\"target\":"); + w.array(&input.geometry.target, 256).await?; + w.out.push_str(",\"fixtures\":"); + w.array(&input.geometry.fixtures, 256).await?; + w.out.push_str(",\"fixture_groups\":"); + w.array(&input.geometry.fixture_groups, 256).await?; + w.out.push_str("}},\"toolpath\":{\"fingerprint\":"); + w.value(&path.fingerprint)?; + w.out.push_str(",\"blocks\":"); + w.array(&path.blocks, 256).await?; + w.out.push_str("}}"); + } + "validation" => { + let r = report.ok_or_else(|| IoError::Invalid("simulate first".into()))?; + // This small metadata object excludes the potentially large cell array. + let metadata = serde_json::json!({ + "version":r.version,"fingerprint":r.fingerprint,"status":r.status, + "finished_dimensions":r.finished_dimensions,"post_version":r.post_version, + "tolerance":r.tolerance,"uncertainty":r.uncertainty,"maximum_height_interval":r.maximum_height_interval, + "stock_complete":r.stock_complete,"stock":r.stock,"findings":r.findings, + "cutting_distance":r.cutting_distance,"rapid_distance":r.rapid_distance,"cutting_minutes":r.cutting_minutes, + "remaining_volume_low":r.remaining_volume_low,"remaining_volume_high":r.remaining_volume_high,"work":r.work, + }); + w.value(&metadata)?; + w.out.pop(); + w.out.push_str(",\"cells\":"); + w.array(&r.cells, 256).await?; + w.out.push('}'); + } + "setup_sheet" => { + w.out = format!( + "{}

{}

Reference three-axis milling — not a qualified machine post.

Fingerprint: {}

Setup

{}

Operations

", + escape(&input.setup.name), + escape(&input.setup.name), + escape(&path.fingerprint), + escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?), + ); + for o in &input.operations { + w.checkpoint().await?; + w.out.push_str(&format!( + "", + escape(&o.name), + o.tool, + o.feed, + o.spindle, + o.bottom + )); + } + w.out.push_str("
NameToolFeed mm/minSpindle rpmBottom mm
{}{}{}{}{}

Tools

");
+            w.html_array(&input.tools).await?;
+            w.out.push_str("

Operation settings and selected geometry

");
+            w.html_array(&input.operations).await?;
+            w.out.push_str("
"); + } + _ => return Err(IoError::Invalid("unknown CAM artifact format".into())), + } + w.checkpoint().await?; + Ok(w.out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::cell::Cell; + fn ready(future: impl Future) -> T { + let mut future = std::pin::pin!(future); + loop { + if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + return value; + } + } + } + fn fixture() -> (JobInput, Toolpath, Report) { + let input = solidcraft_cam::corpus::fixture("face").unwrap(); + let path = solidcraft_cam::generate::generate(input.clone()).unwrap(); + let parsed = solidcraft_cam::nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = solidcraft_cam::verify::simulate(&input, &parsed, Default::default()).unwrap(); + (input, path, report) + } + fn pre_values(html: &str) -> Vec { + html.split("
")
+            .skip(1)
+            .map(|part| {
+                let text = part
+                    .split("
") + .next() + .unwrap() + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + serde_json::from_str(&text).unwrap() + }) + .collect() + } + #[test] + fn cooperative_artifacts_preserve_every_field_geometry_and_html_escaping() { + let (mut input, mut path, mut report) = fixture(); + input.operations[0].name = "ø diff --git a/assets/icons/ui/design/tools/review/review.css b/assets/icons/ui/design/tools/review/review.css new file mode 100644 index 00000000..f6aa119e --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.css @@ -0,0 +1,62 @@ +:root{--bg:#f4f6f9;--fg:#1d2733;--card:#fff;--line:#d5dbe3;--mut:#6b7a8a;--sz:48px} +body.dark{--bg:#1e2530;--fg:#dbe4ee;--card:#262f3c;--line:#3a4657;--mut:#8a9db0} +body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)} +header{position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--line);padding:6px 12px;z-index:5;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center} +h1{font-size:14px;margin:0}.sum{color:var(--mut)} +button,input,select{font:inherit;color:inherit;padding:2px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);cursor:pointer} +button.on{background:var(--review-accent);color:var(--review-accent-ink);border-color:var(--review-accent)} +.grp{display:flex;gap:3px;align-items:center}.grp small{color:var(--mut)} +main{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;align-items:stretch} +.gm{flex:0 0 11px;writing-mode:vertical-rl;transform:rotate(180deg);font:600 8px/11px system-ui;letter-spacing:.04em;color:var(--mut);background:var(--line);border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-transform:uppercase;font-style:normal;max-height:120px;align-self:stretch} +.gm.tab{background:var(--review-accent);color:var(--review-accent-ink)} +.card{position:relative;flex:0 0 118px;background:var(--card);border:1px solid var(--line);border-radius:7px} +.art{display:flex;justify-content:center;align-items:center;padding:8px 0 2px;min-height:calc(var(--sz) + 10px)} +.art svg{width:var(--sz);height:var(--sz)} +.art .ad,.art .sd,.art .sl{display:none} +body.dark .art .al{display:none}body.dark .art .ad{display:inline} +body.small .art.hs .al,body.small .art.hs .ad{display:none} +body.small:not(.dark) .art.hs .sl{display:inline}body.small.dark .art.hs .sd{display:inline} +.meta{padding:2px 6px 5px;line-height:1.35;word-break:break-all}.meta small{color:var(--mut);font-size:10px} +.src,.acc,.st{font-size:10px;color:var(--mut);font-style:normal}.src{color:var(--mut)}.st{color:var(--review-error)} +.ck,.nt{position:absolute;top:3px;width:20px;height:20px;padding:0;line-height:18px;border-radius:50%;font-size:12px;color:var(--mut);opacity:.55} +.ck{right:3px}.nt{left:3px;border-radius:5px} +.card:hover .ck,.card:hover .nt{opacity:1} +.card.final{border-color:var(--review-success)}.card.final .art,.card.final .meta{opacity:.6} +.card.final .ck{background:var(--review-success);color:var(--review-accent-ink);border-color:var(--review-success);opacity:1} +.card.final .nt{display:none} +.card.hasnote{border-color:var(--review-warning)}.card.hasnote .nt{background:var(--review-warning);color:var(--review-accent-ink);border-color:var(--review-warning);opacity:1} +.pop{position:absolute;left:0;top:100%;z-index:9;width:250px;background:var(--card);border:1px solid var(--review-accent);border-radius:7px;padding:6px;box-shadow:0 6px 18px #0004;display:none} +.card.open .pop{display:block}.card.open{z-index:9} +.pop textarea{width:100%;box-sizing:border-box;height:90px;font:12px system-ui;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:5px} +#msg{color:var(--review-error)} +[hidden]{display:none!important} +header .detail{width:100%;display:flex;gap:14px;align-items:center;font-size:11px} +header a{color:var(--fg);text-decoration:underline;text-underline-offset:2px} +.help{color:var(--mut);font-size:11px;margin:8px 12px 0} +.warning:empty{display:none}.warning,.error{color:var(--review-error)} +.warning{width:100%;margin:0;font-size:11px} +#msg{color:var(--mut)}#msg.error{color:var(--review-error)} +button:disabled{cursor:not-allowed;opacity:.3} +button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--review-accent);outline-offset:2px} +#empty{padding:30px;color:var(--mut)} + +.shared{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--mut);font-size:10px;word-break:normal} + +.vision{display:none}body[data-vision="off"] .vision-off,body[data-vision="red-green"] .vision-red-green,body[data-vision="blue-yellow"] .vision-blue-yellow{display:contents} + +.acc{color:var(--icon-accent,var(--mut))} + +/* Temporary palette tuning controls; keep them separate from review/acceptance. */ +#palette-editor{flex-basis:100%;border-bottom:1px solid var(--line);padding:2px 0 8px} +.palette-heading{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-bottom:6px} +#palette-status{color:var(--mut);font-size:11px}#palette-status.error{color:var(--review-error)} +#palette-tokens{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:3px 12px;max-height:180px;overflow:auto} +.palette-token{display:flex;align-items:center;gap:5px;min-width:0;line-height:20px} +.palette-token span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.palette-token input[type=color]{appearance:none;-webkit-appearance:none;box-sizing:border-box;width:19px;height:19px;border:1px solid var(--line);border-radius:50%;padding:0;background:transparent;flex-shrink:0;overflow:hidden} +.palette-token input[type=color]::-webkit-color-swatch-wrapper{padding:0} +.palette-token input[type=color]::-webkit-color-swatch{border:0;border-radius:50%} +.palette-token input[type=color]::-moz-color-swatch{border:0;border-radius:50%} +.palette-token input:disabled{cursor:default;opacity:1} +.palette-knockout{box-sizing:border-box;width:19px;height:19px;flex-shrink:0;border:1px solid var(--line);border-radius:50%;background:repeating-conic-gradient(#8993a0 0% 25%,transparent 0% 50%) 0 0/8px 8px} +body.palette-preview .card.final .art{opacity:1} diff --git a/assets/icons/ui/design/tools/review/review.js b/assets/icons/ui/design/tools/review/review.js new file mode 100644 index 00000000..d71ae4b0 --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.js @@ -0,0 +1,285 @@ +// No framework, build step, external resources or model integration. +const S = @@STATE@@; +const fin = new Set(S.final); +const notes = {...S.notes}; +let base = {final: [...S.final].sort(), notes: {...notes}}; +let filter = 'open', tab = '', dirty = false, timer; +let saves = Promise.resolve(); +const query = document.getElementById('q'); +const message = document.getElementById('msg'); +const cards = [...document.querySelectorAll('.card')]; +const markers = [...document.querySelectorAll('.gm')]; +const staticKey = 'icon-review:' + S.rev; +const prefsKey = 'icon-review-preferences'; +const reloadKey = 'icon-review-reload-view'; + +function state() { + return {final: [...fin].sort(), notes: Object.fromEntries( + Object.entries(notes).filter(([, value]) => value.trim()))}; +} +function payload(snapshot) { return JSON.stringify({base, ...snapshot, rev: S.rev}); } +function showMessage(text, error = false) { + message.textContent = text; + message.classList.toggle('error', error); +} +function run() { + let finals = 0, noted = 0, visible = 0; + for (const card of cards) { + const id = card.dataset.id; + const accepted = fin.has(id), hasNote = Boolean((notes[id] || '').trim()); + card.classList.toggle('final', accepted); + card.classList.toggle('hasnote', hasNote && !accepted); + card.querySelector('.ck').title = accepted ? 'Reopen for review' : 'Accept as final'; + card.querySelector('.ck').setAttribute('aria-label', (accepted ? 'Reopen ' : 'Accept ') + id + (accepted ? '' : ' as final')); + if (accepted) finals++; + if (hasNote && !accepted) noted++; + const matches = (filter === 'all' || (filter === 'open' && !accepted) || + (filter === 'notes' && hasNote && !accepted) || (filter === 'final' && accepted)) && + (!tab || card.dataset.tab === tab) && card.dataset.k.includes(query.value.toLowerCase()); + card.hidden = !matches; + if (matches) visible++; + } + for (const marker of markers) { + let next = marker.nextElementSibling, any = false; + while (next && !next.classList.contains('gm')) { + if (!next.hidden) any = true; + next = next.nextElementSibling; + } + marker.hidden = !any; + } + document.getElementById('cnt').textContent = `${finals} final · ${cards.length - finals} open · ${noted} with notes`; + document.getElementById('empty').hidden = visible > 0; +} +function scheduleSave() { + dirty = true; + clearTimeout(timer); + timer = setTimeout(flush, 250); +} +async function flush() { + clearTimeout(timer); + const snapshot = state(); + saves = saves.catch(() => {}).then(async () => { + if (!S.serving) { + localStorage.setItem(staticKey, JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Static preview: Export downloads the review file. Use --serve to save to the repository.'); + return; + } + const response = await fetch('/api/review', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Save failed'); + base = JSON.parse(JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Saved'); + }); + try { await saves; return true; } + catch (error) { showMessage(error.message, true); return false; } +} +function view() { + return {filter, tab, q: query.value, y: scrollY}; +} +function preferences() { + return {dark: document.body.classList.contains('dark'), vision: document.body.dataset.vision, size: document.documentElement.style.getPropertyValue('--sz')}; +} +function savePreferences() { try { localStorage.setItem(prefsKey, JSON.stringify(preferences())); } catch {} } +function setSize(size) { + document.documentElement.style.setProperty('--sz', size + 'px'); + document.body.classList.toggle('small', size <= 24); + document.querySelectorAll('.sz').forEach(button => button.classList.toggle('on', +button.dataset.s === size)); +} +function selectFilter(value) { + filter = value; + document.querySelectorAll('[data-f]').forEach(button => button.classList.toggle('on', button.dataset.f === value)); +} +function selectTab(value) { + tab = value; + document.querySelectorAll('.chip').forEach(button => button.classList.toggle('on', (button.dataset.tab || '') === value)); +} + +// Temporary palette editor: CSS variables recolour every rendered theme, size +// and vision variant without touching SVGs, notes or acceptance hashes. +const paletteDraftKey = 'icon-review-palette-draft'; +const paletteStatus = document.getElementById('palette-status'); +const paletteApply = document.getElementById('palette-apply'); +const paletteReset = document.getElementById('palette-reset'); +const palettePaints = Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout) + .map(token => [token.id, {light: token.light, dark: token.dark}])); +const paletteInputs = []; +let paletteDirty = false, paletteSaving = false; +function paletteVariable(id, theme) { return '--icon-palette-' + id.replaceAll('.', '-') + '-' + theme; } +function paletteChanges() { + return Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout && + ['light', 'dark'].some(theme => palettePaints[token.id][theme] !== token[theme])) + .map(token => [token.id, {...palettePaints[token.id]}])); +} +function updatePalette() { + for (const [id, paints] of Object.entries(palettePaints)) { + for (const theme of ['light', 'dark']) document.documentElement.style.setProperty(paletteVariable(id, theme), paints[theme]); + } + for (const {input, token, theme} of paletteInputs) { + input.value = palettePaints[token.linked || token.id][theme]; + input.title = `${token.name} · ${theme}: ${input.value}${token.linked ? ' (follows construction stroke)' : ''}`; + } + paletteDirty = Object.keys(paletteChanges()).length > 0; + paletteApply.disabled = !paletteDirty || paletteSaving; + paletteReset.disabled = !paletteDirty || paletteSaving; + try { + if (paletteDirty) sessionStorage.setItem(paletteDraftKey, JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})); + else sessionStorage.removeItem(paletteDraftKey); + } catch {} +} +for (const token of S.palette.tokens) { + const row = document.createElement('div'); row.className = 'palette-token'; + for (const theme of ['light', 'dark']) { + if (token.knockout) { + const swatch = document.createElement('span'); swatch.className = 'palette-knockout'; + swatch.title = token.name + ' · ' + theme + ': transparent cutout'; + swatch.setAttribute('aria-label', swatch.title); row.appendChild(swatch); continue; + } + const input = document.createElement('input'); input.type = 'color'; input.value = token[theme]; + input.disabled = Boolean(token.linked); + input.setAttribute('aria-label', token.name + ' ' + theme + ' colour'); + input.oninput = () => { + palettePaints[token.id][theme] = input.value; + updatePalette(); paletteStatus.textContent = 'Preview only · Apply saves both themes to palette.toml'; + paletteStatus.classList.remove('error'); + }; + paletteInputs.push({input, token, theme}); row.appendChild(input); + } + const label = document.createElement('span'); label.textContent = token.name + (token.linked ? ' ↗' : ''); + row.appendChild(label); document.getElementById('palette-tokens').appendChild(row); +} +document.body.classList.add('palette-preview'); +document.getElementById('palette-all').onclick = () => { selectFilter('all'); selectTab(''); query.value = ''; run(); }; +paletteReset.onclick = () => { + for (const token of S.palette.tokens.filter(token => !token.linked && !token.knockout)) palettePaints[token.id] = {light: token.light, dark: token.dark}; + updatePalette(); paletteStatus.textContent = 'Preview reset · saved palette unchanged'; paletteStatus.classList.remove('error'); +}; +paletteApply.onclick = async () => { + if (!paletteDirty || paletteSaving) return; + if (dirty && !await flush()) return; + if (!S.serving) { + paletteStatus.textContent = 'Preview only · run the review server to apply colours to palette.toml'; return; + } + paletteSaving = true; updatePalette(); + try { + const response = await fetch('/api/palette', {method: 'POST', body: JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Palette save failed'); + S.palette = {revision: result.revision, tokens: result.tokens}; + paletteStatus.textContent = 'Saved to palette.toml · rebuild the app to use these colours'; + paletteStatus.classList.remove('error'); + } catch (error) { paletteStatus.textContent = error.message; paletteStatus.classList.add('error'); } + finally { paletteSaving = false; updatePalette(); } +}; +try { + const draft = JSON.parse(sessionStorage.getItem(paletteDraftKey) || 'null'); + if (draft?.revision === S.palette.revision) { + for (const [id, paints] of Object.entries(draft.changes)) { + if (palettePaints[id] && ['light','dark'].every(theme => /^#[0-9a-f]{6}$/i.test(paints[theme] || ''))) palettePaints[id] = {...paints}; + } + paletteStatus.textContent = 'Unsaved palette preview restored'; + } else if (draft) { paletteStatus.textContent = 'Saved palette changed · older preview was not restored'; } +} catch {} +updatePalette(); +for (const card of cards) { + const id = card.dataset.id; + const pop = document.createElement('div'); + pop.className = 'pop'; + const textarea = document.createElement('textarea'); + textarea.placeholder = 'What should change in this icon?'; + textarea.setAttribute('aria-label', 'Notes for ' + id); + textarea.value = notes[id] || ''; + pop.appendChild(textarea); card.appendChild(pop); + textarea.oninput = () => { notes[id] = textarea.value; run(); scheduleSave(); }; + card.querySelector('.ck').onclick = () => { + if (card.dataset.missing === 'true') return; + fin.has(id) ? fin.delete(id) : fin.add(id); + card.classList.remove('open'); run(); scheduleSave(); + }; + card.querySelector('.nt').onclick = event => { + const open = !card.classList.contains('open'); + cards.forEach(other => other.classList.remove('open')); + card.classList.toggle('open', open); + if (open) textarea.focus(); + event.stopPropagation(); + }; + pop.onclick = event => event.stopPropagation(); +} +document.addEventListener('click', () => cards.forEach(card => card.classList.remove('open'))); +document.addEventListener('keydown', event => { + if (event.key === 'Escape') cards.forEach(card => card.classList.remove('open')); +}); +document.querySelectorAll('[data-f]').forEach(button => button.onclick = () => { selectFilter(button.dataset.f); run(); }); +document.querySelectorAll('.chip').forEach(button => button.onclick = () => { selectTab(button.dataset.tab || ''); run(); }); +document.querySelectorAll('.sz').forEach(button => button.onclick = () => { setSize(+button.dataset.s); savePreferences(); }); +document.getElementById('dk').onclick = function () { + document.body.classList.toggle('dark'); this.classList.toggle('on'); savePreferences(); +}; +document.getElementById('vision').onchange = event => { + document.body.dataset.vision = event.target.value; savePreferences(); +}; +query.oninput = run; +document.getElementById('rescan').onclick = async () => { + if (!dirty || await flush()) location.reload(); +}; +document.getElementById('exp').onclick = async () => { + if (dirty && !await flush()) return; + if (!S.serving) { + const snapshot = state(); + const accepted = Object.fromEntries(snapshot.final.map(id => [id, S.hashes[id]])); + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([JSON.stringify({version: 1, final: accepted, notes: snapshot.notes}, null, 1)], {type: 'application/json'})); + link.download = 'review.json'; link.click(); URL.revokeObjectURL(link.href); + return; + } + try { + await saves; + const snapshot = state(); + const response = await fetch('/api/export', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Export failed'); + base = JSON.parse(JSON.stringify(snapshot)); + showMessage(result.sent ? `Queued ${result.sent} note${result.sent === 1 ? '' : 's'} in the local review inbox` : 'No open notes to export'); + } catch (error) { showMessage(error.message, true); } +}; +try { + if (!S.serving) { + const saved = JSON.parse(localStorage.getItem(staticKey) || 'null'); + if (saved) { fin.clear(); saved.final.forEach(id => fin.add(id)); Object.assign(notes, saved.notes); } + } + const prefs = JSON.parse(localStorage.getItem(prefsKey) || 'null'); + if (prefs) { + if (prefs.vision && ['off', 'red-green', 'blue-yellow'].includes(prefs.vision)) { document.body.dataset.vision = prefs.vision; document.getElementById('vision').value = prefs.vision; } + if (prefs.dark) document.getElementById('dk').click(); + const size = parseInt(prefs.size); + if ([16,24,48,64].includes(size)) setSize(size); + } + // Only an automatic artwork reload restores filters. A normal opening always + // starts at Open / Everything, so yesterday's filter cannot hide new artwork. + const restored = JSON.parse(sessionStorage.getItem(reloadKey) || 'null'); + sessionStorage.removeItem(reloadKey); + if (restored) { + selectFilter(restored.filter); selectTab(restored.tab); query.value = restored.q; + requestAnimationFrame(() => scrollTo(0, restored.y)); + } +} catch {} +run(); +if (S.serving) setInterval(async () => { + if (dirty || paletteDirty || paletteSaving || document.activeElement?.tagName === 'TEXTAREA') return; + try { + const response = await fetch('/api/review'); + const current = await response.json(); + if (current.rev && current.rev !== S.rev) { + sessionStorage.setItem(reloadKey, JSON.stringify(view())); + location.reload(); return; + } + if (JSON.stringify([current.final, current.notes]) !== JSON.stringify([[...fin].sort(), notes])) { + fin.clear(); current.final.forEach(id => fin.add(id)); + Object.keys(notes).forEach(id => delete notes[id]); Object.assign(notes, current.notes); + base = {final: [...current.final].sort(), notes: {...notes}}; + cards.forEach(card => { card.querySelector('textarea').value = notes[card.dataset.id] || ''; }); + run(); + } + } catch { showMessage('Review server unavailable; reconnect before saving.', true); } +}, 3000); diff --git a/assets/icons/ui/design/tools/review_state.py b/assets/icons/ui/design/tools/review_state.py new file mode 100644 index 00000000..2b78ae2a --- /dev/null +++ b/assets/icons/ui/design/tools/review_state.py @@ -0,0 +1,76 @@ +"""Shared icon review state: read and write design/review.json (standard library only). + + {"version": 1, "final": {"": ""}, "notes": {"": "text"}} + +Legacy acceptance lists migrate once when the review server starts; existing hashes are never refreshed implicitly. +""" +import os, re, json, glob, hashlib, tomllib, threading, uuid + +LOCK = threading.RLock() + +D = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # .../assets/icons/ui/design +REVIEW = D + '/review.json' + +def aliases(): + return tomllib.load(open(D + '/aliases.toml', 'rb')).get('alias', {}) +def master_of(icon_id, al=None): + v = (aliases() if al is None else al).get(icon_id) + return (v['master'] if isinstance(v, dict) else v) if v else icon_id +def master_hash(master): + h = hashlib.sha256() + for f in sorted(glob.glob(D + '/svg/*/' + master + '.svg')): + h.update(os.path.relpath(f, D).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read()) + return h.hexdigest() +def load(): + try: r = json.load(open(REVIEW, encoding='utf-8')) + except FileNotFoundError: r = {} + fin = r.get('final', {}) + if isinstance(fin, list): fin = {i: '' for i in fin} + return {'final': dict(fin), 'notes': {k: v for k, v in r.get('notes', {}).items() if isinstance(v, str) and v.strip()}} +def valid_final(state=None): + """Acceptance belongs to a particular artwork revision, not just an id.""" + state = load() if state is None else state + al = aliases() + return {i: h for i, h in state['final'].items() + if h and glob.glob(D + '/svg/*/' + master_of(i, al) + '.svg') + and h == master_hash(master_of(i, al))} +def save(final, notes): + """final: {id: hash or ''}; a missing hash is filled in from the master as it is now.""" + ok = lambda i: re.fullmatch(r'[a-z][a-z0-9_]*', i) + al = aliases() + fin = {i: (h or master_hash(master_of(i, al))) for i, h in final.items() if ok(i)} + nts = {k: v for k, v in notes.items() if ok(k) and isinstance(v, str) and v.strip()} + temporary = REVIEW + '.' + uuid.uuid4().hex + '.tmp' + with LOCK: + try: + with open(temporary, 'w', encoding='utf-8') as f: + json.dump({'version': 1, 'final': dict(sorted(fin.items())), 'notes': dict(sorted(nts.items()))}, f, indent=1); f.write('\n') + os.replace(temporary, REVIEW) + finally: + if os.path.exists(temporary): os.unlink(temporary) +def resolve(masters_or_ids): + """The icons were redrawn: drop their notes, and the notes on aliases of the same masters. Returns the ids cleared.""" + r = load(); al = aliases(); done = set(masters_or_ids) + cleared = sorted(i for i in r['notes'] if i in done or master_of(i, al) in done) + for i in cleared: del r['notes'][i] + if cleared: save(r['final'], r['notes']) + return cleared + + +def resolve_export(request, written): + """Clear only still-matching exported notes on masters actually redrawn. + + Call after applying a snapshot's artwork feedback. Newer notes and final + aliases are retained; this helper does not move requests or draw anything. + """ + with LOCK: + state = load() + protected = {master_of(i) for i in valid_final(state)} + cleared = [] + for icon, expected in request.get('notes', {}).items(): + master = master_of(icon) + if master in written and master not in protected and state['notes'].get(icon) == expected: + del state['notes'][icon] + cleared.append(icon) + if cleared: save(state['final'], state['notes']) + return sorted(cleared) diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs deleted file mode 100644 index 56583146..00000000 --- a/assets/icons/ui/source/icons.rs +++ /dev/null @@ -1,1621 +0,0 @@ -//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a -//! 24 × 24 grid and scaled to the target rectangle. - -use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; - -/// `n + 1` points along the cubic Bézier curve with control points `c`. -fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> { - (0..=n) - .map(|i| i as f32 / n as f32) - .map(|t| { - let u = 1.0 - t; - let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; - (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) - }) - .collect() -} - -/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre -/// first). -fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> { - (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect() -} - -/// A gear's toothed outline about (x, y): `n` teeth from the root radius `root` out to `tip`, -/// turned by `phase` radians. -fn teeth(x: f32, y: f32, root: f32, tip: f32, n: usize, phase: f32) -> Vec<(f32, f32)> { - let step = std::f32::consts::TAU / n as f32; - let at = |a: f32, r: f32| (x + r * a.cos(), y - r * a.sin()); - (0..n) - .flat_map(|k| { - let a = phase + step * k as f32; - [at(a - 0.3 * step, root), at(a - 0.15 * step, tip), at(a + 0.15 * step, tip), at(a + 0.3 * step, root)] - }) - .collect() -} - -struct Pen<'a> { - p: &'a Painter, - r: Rect, - ink: Color32, - fill: Color32, - accent: Color32, - w: f32, -} - -impl Pen<'_> { - fn at(&self, x: f32, y: f32) -> Pos2 { - pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height()) - } - fn s(&self) -> f32 { - self.r.width() / 24.0 - } - fn line(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::line(v, Stroke::new(self.w, c))); - } - fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c))); - } - fn closed(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::closed_line(v, Stroke::new(self.w, c))); - } - fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) { - self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c)); - } - fn dot(&self, x: f32, y: f32, c: Color32) { - self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c); - } - fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) { - let n = 24; - let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect(); - self.line(&pts, c); - } - fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) { - self.line(&[from, to], c); - let d = vec2(to.0 - from.0, to.1 - from.1).normalized(); - let n = vec2(-d.y, d.x); - let tip = vec2(to.0, to.1); - let a = tip - d * 3.5 + n * 2.2; - let b = tip - d * 3.5 - n * 2.2; - self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c); - } - /// Isometric box from a 2D footprint height. - fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) { - let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)]; - let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)]; - let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)]; - self.poly(&front, self.fill.gamma_multiply(0.85), self.ink); - self.poly(&side, self.fill.gamma_multiply(0.7), self.ink); - self.poly(&top, self.fill, self.ink); - } - /// A cubic Bézier curve through control points `c`. - fn curve(&self, c: [(f32, f32); 4], col: Color32) { - self.line(&bezier(c, 16), col); - } - fn dashed(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s())); - } - /// Fill the strip between two polylines that run the same way, such as the front and back - /// edges of a curved sheet. Unlike `poly`, the strip need not be convex. - fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) { - let mut m = Mesh::default(); - for (p, q) in a.iter().zip(b) { - m.colored_vertex(self.at(p.0, p.1), fill); - m.colored_vertex(self.at(q.0, q.1), fill); - } - let n = a.len().min(b.len()) as u32; - for i in 1..n { - let k = 2 * i; - m.add_triangle(k - 2, k - 1, k); - m.add_triangle(k - 1, k + 1, k); - } - self.p.add(Shape::mesh(m)); - } -} - -/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than -/// the generic icon weight (`Pen::w`). It is sized from whichever side of `r` is smaller. A -/// short, wide button and a square toolbar slot both get a stroke their gaps can survive next -/// to. -fn line_type_stroke(r: Rect) -> f32 { - (r.width().min(r.height()) / 24.0 * 2.0).max(1.6) -} - -/// The Centerline icon's dot radius. It is sized from `r`. The stroke width plays no part in it. -/// It stays a round mark even next to a heavy stroke. -fn line_type_dot_radius(r: Rect) -> f32 { - (r.width().min(r.height()) * 0.16).max(2.4) -} - -/// Paint the icon `name` into `r`. `accent` highlights the "action" part. -pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) { - paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons); -} - -/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`). -pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) { - let ic = *ic; - let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) }; - let a = pen.accent; - match name { - "sketch" => { - pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink); - pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a); - } - "finish" => { - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent); - pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a); - } - "extrude" => { - pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0); - pen.arrow((12.0, 9.0), (12.0, 2.5), a); - } - "revolve" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink); - pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a); - pen.arrow((15.0, 20.4), (18.5, 19.0), a); - } - "box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0), - "cylinder" => { - pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT); - pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink); - pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink); - let e = |y: f32, fillc: Color32| { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink))); - }; - pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink); - e(6.0, pen.fill); - } - "sphere" => { - pen.circle(12.0, 12.0, 8.5, pen.fill, ink); - pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5)); - p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint); - } - "torus" => { - let pts: Vec = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink))); - let pts: Vec = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink))); - } - "fillet" => { - pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT); - pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink); - pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink); - pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink); - pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); - } - "chamfer" => { - pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink); - pen.line(&[(12.0, 4.0), (20.0, 12.0)], a); - } - "combine" => { - pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink); - pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a); - } - "pattern_rect" => { - for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] { - pen.poly( - &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)], - if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT }, - if x == 4.0 && y == 4.0 { ink } else { a }, - ); - } - } - "pattern_circ" => { - for i in 0..6 { - let t = i as f32 / 6.0 * std::f32::consts::TAU; - pen.circle( - 12.0 + 7.5 * t.cos(), - 12.0 + 7.5 * t.sin(), - 2.4, - if i == 0 { pen.fill } else { Color32::TRANSPARENT }, - if i == 0 { ink } else { a }, - ); - } - } - "hole" => { - pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink); - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a))); - pen.line(&[(7.0, 8.0), (7.0, 16.0)], a); - pen.line(&[(17.0, 8.0), (17.0, 16.0)], a); - } - "sweep" => { - pen.arc(20.0, 20.0, 14.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, ink); - pen.circle(6.0, 20.0, 3.0, pen.fill, a); - pen.circle(20.0, 6.0, 3.0, pen.fill, a); - } - "loft" => { - pen.poly(&[(3.0, 20.0), (15.0, 20.0), (21.0, 15.0), (9.0, 15.0)], pen.fill, ink); - pen.circle(12.0, 6.0, 3.5, Color32::TRANSPARENT, a); - pen.line(&[(3.0, 20.0), (8.5, 6.0)], a); - pen.line(&[(21.0, 15.0), (15.5, 6.0)], a); - } - "shell" => { - pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0); - pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a); - } - "draft" => { - pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink); - pen.line(&[(20.0, 20.0), (20.0, 5.0)], a); - } - "split" => { - pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); - pen.line(&[(8.0, 2.0), (16.0, 22.0)], a); - } - "mirror" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink); - pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a); - } - "lookat" => { - pen.poly(&[(4.0, 6.0), (16.0, 6.0), (16.0, 18.0), (4.0, 18.0)], pen.fill, ink); - pen.arrow((22.0, 12.0), (13.0, 12.0), a); - } - "style" => { - pen.poly(&[(12.0, 3.0), (20.0, 7.5), (12.0, 12.0), (4.0, 7.5)], pen.fill, ink); - pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink); - pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink); - } - // Original bug glyph: rounded body, antennae and three pairs of legs. - "bug_report" => { - pen.circle(12.0, 13.5, 5.0, Color32::TRANSPARENT, ink); - pen.line(&[(12.0, 9.0), (12.0, 18.0)], ink); - pen.line(&[(9.5, 8.5), (8.0, 5.0)], ink); - pen.line(&[(14.5, 8.5), (16.0, 5.0)], ink); - for y in [10.0, 13.5, 17.0] { - pen.line(&[(3.0, y), (7.0, y)], ink); - pen.line(&[(17.0, y), (21.0, y)], ink); - } - } - // Update: an arrow down onto a tray (a newer release can be downloaded). - "update" => { - pen.line(&[(12.0, 3.5), (12.0, 14.5)], ink); - pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink); - pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink); - } - // Agent chat: a speech bubble with three dots. - "agent_chat" => { - pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink); - for x in [8.5, 12.0, 15.5] { - pen.dot(x, 10.5, ink); - } - } - // Help: a question mark in a ring. - "help" => { - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - pen.arc(12.0, 9.5, 3.2, -0.6, 3.4, ink); - pen.line(&[(13.9, 12.0), (12.0, 13.6), (12.0, 15.0)], ink); - pen.dot(12.0, 17.8, ink); - } - "sun" => { - pen.circle(12.0, 12.0, 4.5, a, ink); - for i in 0..8 { - let t = i as f32 / 8.0 * std::f32::consts::TAU; - pen.line(&[(12.0 + 7.0 * t.cos(), 12.0 + 7.0 * t.sin()), (12.0 + 9.5 * t.cos(), 12.0 + 9.5 * t.sin())], ink); - } - } - "moon" => { - let outer: Vec = - (0..=24).map(|i| -0.6 + i as f32 / 24.0 * 4.4).map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 + 8.0 * t.sin())).collect(); - let inner: Vec = - (0..=24).rev().map(|i| -0.9 + i as f32 / 24.0 * 3.9).map(|t| pen.at(15.5 + 6.5 * t.cos(), 9.5 + 6.5 * t.sin())).collect(); - let mut pts = outer; - pts.extend(inner); - p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink))); - } - "section" => { - pen.poly(&[(4.0, 8.0), (14.0, 4.0), (20.0, 8.0), (10.0, 12.0)], pen.fill, ink); - pen.poly(&[(4.0, 8.0), (10.0, 12.0), (10.0, 20.0), (4.0, 16.0)], pen.fill, ink); - pen.poly(&[(10.0, 12.0), (20.0, 8.0), (20.0, 16.0), (10.0, 20.0)], a, ink); - } - "presspull" => { - pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink); - pen.arrow((12.0, 15.0), (12.0, 3.0), a); - } - "move" => { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { - pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a); - } - } - "configuration_table" => { - pen.poly(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink); - pen.line(&[(3.0, 9.0), (21.0, 9.0)], a); - pen.line(&[(3.0, 14.0), (21.0, 14.0)], ink); - pen.line(&[(9.0, 4.0), (9.0, 20.0)], ink); - } - "params" => { - pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink); - pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink); - pen.line(&[(14.0, 13.0), (20.0, 20.0)], a); - pen.line(&[(20.0, 13.0), (14.0, 20.0)], a); - } - "compute" => { - pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink); - pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a); - pen.arrow((5.0, 9.0), (4.6, 13.0), ink); - pen.arrow((19.0, 15.0), (19.4, 11.0), a); - } - "delete" => { - pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent); - pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent); - } - "measure" => { - pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink); - for i in 0..5 { - let t = 4.0 + i as f32 * 2.6; - pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink); - } - } - "line" => { - pen.line(&[(4.0, 19.0), (20.0, 5.0)], a); - pen.dot(4.0, 19.0, ink); - pen.dot(20.0, 5.0, ink); - } - "rect" | "rect3" | "rect_center" => { - pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a); - match name { - "rect" => { - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - "rect3" => { - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - _ => { - pen.dot(12.0, 12.0, ink); - pen.dot(20.0, 6.0, ink); - } - } - } - "rect_rounded" => { - // Straight sides between quarter arcs of radius 3 at the corners. - pen.line(&[(7.0, 6.0), (17.0, 6.0)], a); - pen.line(&[(20.0, 9.0), (20.0, 15.0)], a); - pen.line(&[(17.0, 18.0), (7.0, 18.0)], a); - pen.line(&[(4.0, 15.0), (4.0, 9.0)], a); - let q = std::f32::consts::FRAC_PI_2; - pen.arc(17.0, 9.0, 3.0, 0.0, q, a); - pen.arc(7.0, 9.0, 3.0, q, 2.0 * q, a); - pen.arc(7.0, 15.0, 3.0, 2.0 * q, 3.0 * q, a); - pen.arc(17.0, 15.0, 3.0, 3.0 * q, 4.0 * q, a); - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - "circle" | "circle2" | "circle3" => { - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a); - match name { - "circle" => { - pen.dot(12.0, 12.0, ink); - pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink); - } - "circle2" => { - pen.dot(4.0, 12.0, ink); - pen.dot(20.0, 12.0, ink); - } - _ => { - pen.dot(4.0, 12.0, ink); - pen.dot(12.0, 4.0, ink); - pen.dot(20.0, 12.0, ink); - } - } - } - "arc3" | "arc_center" => { - pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a); - pen.dot(3.0, 16.0, ink); - pen.dot(21.0, 16.0, ink); - if name == "arc3" { - pen.dot(12.0, 7.0, ink); - } else { - pen.dot(12.0, 16.0, ink); - } - } - "polygon" => { - let pts: Vec<(f32, f32)> = - (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - pen.closed(&pts, a); - pen.dot(12.0, 12.0, ink); - } - "slot" => { - pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a); - pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a); - pen.line(&[(8.0, 7.0), (16.0, 7.0)], a); - pen.line(&[(8.0, 17.0), (16.0, 17.0)], a); - pen.dot(8.0, 12.0, ink); - pen.dot(16.0, 12.0, ink); - } - "point" => { - pen.dot(12.0, 12.0, a); - pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink); - } - "dimension" => { - pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink); - pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink); - pen.arrow((12.0, 11.0), (4.5, 11.0), a); - pen.arrow((12.0, 11.0), (19.5, 11.0), a); - pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink); - } - // The Sketch Palette's line type selector (normal, construction, centerline) paints all - // three on the same diagonal, from (3, 19) to (19, 3). Only the dash pattern changes - // between them. Normal draws in `ink` and ignores `accent`. It is a plain line. It is - // never construction geometry. Its colour must stay fixed even if a caller passes the - // construction orange for it. Construction and centerline draw only in `accent` and - // ignore `ink`. The caller decides the colour. The palette and the toolbar both pass - // the theme's construction orange. - // - // These three are drawn small. The Sketch Palette draws them down to about 18 px - // square. At that size a repeating dash pattern loses its gaps. It reads as a solid - // line. Two patterns that only differ in dash phase read as the same texture too. - // Normal, Construction and Centerline instead get three different silhouettes. One is - // an unbroken line. One is a line broken by a single gap. One is a line broken by a - // single gap with a bold dot sitting in it. A viewer can name the shape on sight. No - // rhythm needs measuring. The stroke width and the dot radius both come from the rect - // actually passed in. That keeps the shapes legible in the palette, the toolbar and the - // command palette, at every size any of them uses. - "normal" => { - let w = line_type_stroke(r); - p.add(Shape::line(vec![pen.at(3.0, 19.0), pen.at(19.0, 3.0)], Stroke::new(w, ink))); - } - "construction" => { - // One gap, no dot. It is an unmistakably broken line. Nothing sits in the break. - let w = line_type_stroke(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.35), (0.65, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - } - "centerline" => { - // The same gap as Construction, with a bold dot centred in it. The silhouette is - // line, dot, line. The dot's radius comes from the rect. It reads as a distinct - // round mark on its own, never as a thicker stretch of the stroke. The dashes stop - // well short of it on both sides. The dot never touches either stroke. It always - // reads as a separate shape. - let w = line_type_stroke(r); - let dot_r = line_type_dot_radius(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.26), (0.74, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - p.circle_filled(at(0.5), dot_r, a); - } - "infinite" => { - // An infinite line (#1359): Construction's broken line, with an open arrowhead at - // each end saying it runs on. Accent only, like Construction and Centerline. - let w = line_type_stroke(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.38), (0.62, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - // Each head: two short strokes back from the tip, either side of the line. - let tip_len = 0.3; - for (tip, back) in [(0.0f32, tip_len), (1.0, 1.0 - tip_len)] { - let (t, b) = (at(tip), at(back)); - let along = b - t; - let side = egui::vec2(-along.y, along.x) * 0.55; - p.add(Shape::line(vec![b + side, t, b - side], Stroke::new(w, a))); - } - } - "c_hv" => { - pen.line(&[(4.0, 18.0), (20.0, 18.0)], a); - pen.line(&[(6.0, 4.0), (6.0, 14.0)], a); - } - "c_coincident" => { - pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink); - pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink); - pen.circle(12.0, 12.0, 2.6, ic.highlight, a); - } - "c_tangent" => { - pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink); - pen.line(&[(3.0, 8.0), (21.0, 8.0)], a); - } - "c_equal" => { - pen.line(&[(5.0, 9.0), (19.0, 9.0)], a); - pen.line(&[(5.0, 15.0), (19.0, 15.0)], a); - } - "c_parallel" => { - pen.line(&[(5.0, 19.0), (13.0, 5.0)], a); - pen.line(&[(11.0, 19.0), (19.0, 5.0)], a); - } - "c_perpendicular" => { - pen.line(&[(4.0, 19.0), (20.0, 19.0)], a); - pen.line(&[(12.0, 19.0), (12.0, 5.0)], a); - pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink); - } - "c_fix" => { - pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a); - pen.line(&[(12.0, 13.0), (12.0, 21.0)], a); - pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink); - } - "c_midpoint" => { - pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink); - pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a); - } - "c_concentric" => { - pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a); - pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a); - } - "c_collinear" => { - pen.line(&[(3.0, 19.0), (10.0, 12.0)], a); - pen.line(&[(14.0, 8.0), (21.0, 1.0)], a); - } - "c_symmetry" => { - pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); - pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a); - pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a); - } - "undo" | "redo" => { - let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) }; - pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink); - pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink); - } - "new" => { - pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink); - pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink); - } - "open" => { - pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink); - } - "save" => { - pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink); - pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink); - } - "export" => { - pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink); - pen.arrow((10.0, 14.0), (20.0, 4.0), a); - } - "import" => { - pen.iso_box(4.0, 12.0, 12.0, 7.0, 7.0); - pen.arrow((20.0, 3.0), (12.0, 11.0), a); - } - "home" => { - pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink); - pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink); - } - "fit" => { - for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] { - pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink); - } - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink); - } - "orbit" => { - pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink); - pen.arrow((17.0, 17.5), (19.6, 14.5), a); - pen.dot(12.0, 12.0, ink); - } - "spin" => { - // A turntable: an ellipse with an arrow running round it, a post in the middle. - let pts: Vec = (0..=28).map(|i| 0.2 + i as f32 / 28.0 * 5.6).map(|t| pen.at(12.0 + 9.0 * t.cos(), 16.0 + 3.5 * t.sin())).collect(); - p.add(Shape::line(pts, Stroke::new(pen.w, ink))); - pen.arrow((17.0, 19.0), (20.5, 17.0), a); - pen.line(&[(12.0, 15.5), (12.0, 4.0)], ink); - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 4.0), (9.0, 4.0)], Color32::TRANSPARENT, ink); - } - "pan" => { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { - pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink); - } - } - "zoom" => { - pen.circle(10.0, 10.0, 6.0, ic.highlight, ink); - pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink); - } - "perspective" => { - pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink); - pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink); - } - "eye" => { - pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink); - pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink); - pen.circle(12.0, 12.0, 2.6, ink, ink); - } - "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0), - // Component: a box with a smaller one stacked on it. - "component" => { - pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0); - pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0); - } - "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink), - "origin" => { - pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]); - pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]); - pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]); - } - "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink), - "axis" => { - pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink); - pen.circle(4.0, 20.0, 2.0, ink, ink); - } - "settings" => { - // A gear: a solid ring with six chunky, square-ish teeth and a see-through centre - // hole (#290; thin rays read as a sun). - let (teeth, root, tip, hole) = (6usize, 7.0f32, 10.5f32, 3.2f32); - let pitch = std::f32::consts::TAU / teeth as f32; - // Half-widths (radians) of a tooth at its root and at its tip: nearly parallel sides. - let (half_root, half_tip) = (pitch * 0.3, pitch * 0.22); - let polar = |a: f32, r: f32| (12.0 + r * a.cos(), 12.0 + r * a.sin()); - let start = -std::f32::consts::FRAC_PI_2; - let mut outline = Vec::new(); - for i in 0..teeth { - let c = start + i as f32 * pitch; - // The tooth, filled; it reaches into the ring so no seam shows. - let quad = [polar(c - half_root, root - 1.0), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root - 1.0)]; - let v: Vec = quad.iter().map(|(x, y)| pen.at(*x, *y)).collect(); - p.add(Shape::convex_polygon(v, pen.fill, Stroke::NONE)); - outline.extend([polar(c - half_root, root), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root)]); - // The root circle up to the next tooth. - for k in 1..4 { - let a = c + half_root + (pitch - 2.0 * half_root) * k as f32 / 4.0; - outline.push(polar(a, root)); - } - } - // The ring: a thick stroke, so the hole stays transparent on any background. - p.circle_stroke(pen.at(12.0, 12.0), (root + hole) * 0.5 * pen.s(), Stroke::new((root - hole) * pen.s(), pen.fill)); - pen.closed(&outline, ink); - pen.circle(12.0, 12.0, hole, Color32::TRANSPARENT, ink); - } - // Sketch tools (sketch-style: thin outlines, accent for what the tool makes). - "canvas" | "decal" => { - if name == "canvas" { - pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 19.0), (3.0, 19.0)], pen.fill, ink); - } else { - pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 13.0), (15.0, 19.0), (3.0, 19.0)], pen.fill, ink); - pen.line(&[(21.0, 13.0), (15.0, 13.0), (15.0, 19.0)], ink); - } - pen.line(&[(5.0, 17.0), (9.5, 10.5), (12.5, 14.5), (14.5, 12.0), (18.0, 16.5)], a); - pen.circle(16.5, 8.5, 1.6, a, a); - } - "dxf" | "svg" => { - pen.poly(&[(5.0, 2.5), (15.0, 2.5), (19.5, 7.0), (19.5, 21.5), (5.0, 21.5)], pen.fill, ink); - pen.line(&[(15.0, 2.5), (15.0, 7.0), (19.5, 7.0)], ink); - let label = if name == "dxf" { "DXF" } else { "SVG" }; - p.text(pen.at(12.2, 15.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(6.4 * pen.s()), a); - } - "c_smooth" => { - let pts: Vec<(f32, f32)> = - (0..=24).map(|i| i as f32 / 24.0).map(|t| (3.0 + 18.0 * t, 12.0 - 7.0 * (t * std::f32::consts::TAU).sin())).collect(); - let (l, r) = pts.split_at(13); - pen.line(l, ink); - let mut r2 = vec![pts[12]]; - r2.extend_from_slice(r); - pen.line(&r2, a); - pen.dot(12.0, 12.0, ink); - } - "c_polygon" => { - let pts: Vec<(f32, f32)> = - (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU).map(|t| (12.0 + 8.5 * t.cos(), 12.0 + 8.5 * t.sin())).collect(); - pen.closed(&pts, a); - pen.line(&[(12.0, 12.0), (20.5, 12.0)], ink); - pen.dot(12.0, 12.0, ink); - } - "auto_constrain" | "constrainer" => { - pen.closed(&[(3.0, 8.0), (15.0, 8.0), (15.0, 20.0), (3.0, 20.0)], ink); - if name == "auto_constrain" { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] { - let k = if dx * dy == 0.0 { 4.0 } else { 2.5 }; - pen.line(&[(18.0, 6.0), (18.0 + k * dx, 6.0 + k * dy)], a); - } - } else { - pen.line(&[(13.0, 6.0), (16.0, 9.0), (21.5, 2.5)], a); - } - pen.line(&[(6.0, 17.0), (12.0, 17.0)], a); - pen.line(&[(6.0, 11.0), (6.0, 14.0)], a); - } - "project" | "project_surface" => { - if name == "project" { - pen.poly(&[(2.0, 18.0), (12.0, 14.0), (22.0, 18.0), (12.0, 22.0)], pen.fill, ink); - pen.closed(&[(6.0, 18.0), (12.0, 15.6), (18.0, 18.0), (12.0, 20.4)], a); - } else { - pen.line(&[(2.0, 19.0), (7.0, 16.0), (12.0, 17.5), (17.0, 15.5), (22.0, 18.0)], ink); - pen.line(&[(6.0, 19.5), (12.0, 20.5), (18.0, 18.5)], a); - } - pen.iso_box(7.0, 5.0, 5.0, 5.0, 4.0); - pen.arrow((12.0, 10.5), (12.0, 15.0), a); - } - "intersect" | "intersection_curve" => { - pen.iso_box(6.0, 6.0, 7.0, 5.0, 11.0); - if name == "intersect" { - pen.line(&[(2.0, 13.0), (14.0, 8.0), (22.0, 12.0)], ink.gamma_multiply(0.6)); - pen.line(&[(6.0, 13.0), (11.0, 15.5), (18.0, 12.0)], a); - } else { - pen.arc(12.0, 26.0, 13.0, 0.6, 2.55, ink.gamma_multiply(0.6)); - pen.line(&[(6.0, 14.5), (11.0, 16.5), (18.0, 13.5)], a); - } - } - "include" => { - pen.iso_box(4.0, 9.0, 10.0, 7.0, 9.0); - pen.line(&[(4.0, 9.0), (11.0, 12.5), (21.0, 7.5)], a); - pen.dot(11.0, 12.5, a); - } - "spun" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.line(&[(14.0, 5.0), (18.0, 9.0), (18.0, 15.0), (14.0, 19.0)], ink); - pen.line(&[(10.0, 5.0), (6.0, 9.0), (6.0, 15.0), (10.0, 19.0)], a); - pen.arc(12.0, 12.0, 6.0, 0.3, 2.8, ink.gamma_multiply(0.6)); - } - "iso_curve" => { - pen.poly(&[(3.0, 16.0), (10.0, 6.0), (21.0, 8.0), (14.0, 19.0)], pen.fill, ink); - pen.line(&[(6.5, 11.0), (17.5, 13.5)], ink.gamma_multiply(0.5)); - pen.line(&[(7.7, 17.3), (14.8, 6.8)], a); - } - "fit_section" => { - pen.closed(&[(3.0, 18.0), (8.0, 6.0), (14.0, 15.0), (19.0, 5.0), (21.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(8.0, 6.0), (3.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(14.0, 15.0), (3.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(14.0, 15.0), (21.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(2.0, 12.0), (7.0, 10.5), (12.0, 12.5), (17.0, 10.0), (22.0, 11.5)], a); - } - "sketch_mirror" => { - p.add(Shape::dashed_line(&[pen.at(12.0, 2.0), pen.at(12.0, 22.0)], Stroke::new(pen.w, ink), 2.5 * pen.s(), 1.8 * pen.s())); - pen.closed(&[(3.0, 6.0), (9.0, 10.0), (9.0, 18.0), (3.0, 18.0)], ink); - pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a); - } - "sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a), - "pattern_flip" => { - // Opposing arrows show reversal, with a short reference tick between them. - pen.arrow((4.0, 7.0), (20.0, 7.0), a); - pen.arrow((20.0, 17.0), (4.0, 17.0), ink); - pen.line(&[(12.0, 10.0), (12.0, 14.0)], ink); - } - "pattern_center" => { - // An original on a reference axis, with one equally spaced copy on either side. - pen.line(&[(12.0, 3.0), (12.0, 21.0)], a); - pen.line(&[(4.0, 12.0), (20.0, 12.0)], ink); - for x in [4.0, 12.0, 20.0] { - pen.circle(x, 12.0, 2.4, fill, if x == 12.0 { a } else { ink }); - } - } - "sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a), - "line_mid" => { - pen.line(&[(3.0, 19.0), (21.0, 5.0)], a); - pen.dot(12.0, 12.0, ink); - pen.dot(21.0, 5.0, ink); - pen.circle(12.0, 12.0, 3.0, Color32::TRANSPARENT, ink); - } - "arc_tangent" => { - pen.line(&[(2.0, 18.0), (11.0, 18.0)], ink); - pen.arc(11.0, 10.0, 8.0, -std::f32::consts::FRAC_PI_2, 0.6, a); - pen.dot(11.0, 18.0, ink); - } - "circle_tt" | "circle_ttt" => { - if name == "circle_ttt" { - pen.closed(&[(3.0, 20.0), (21.0, 20.0), (12.0, 4.0)], ink); - } else { - pen.line(&[(21.0, 20.0), (3.0, 20.0), (12.0, 4.0)], ink); - } - pen.circle(12.0, 14.74, 5.26, Color32::TRANSPARENT, a); - } - "arc_slot" => { - let (t0, t1) = (0.75_f32, 2.4_f32); - pen.arc(12.0, 21.0, 13.0, t0, t1, a); - pen.arc(12.0, 21.0, 7.0, t0, t1, a); - pen.arc(12.0 + 10.0 * t0.cos(), 21.0 - 10.0 * t0.sin(), 3.0, t0, t0 - std::f32::consts::PI, a); - pen.arc(12.0 + 10.0 * t1.cos(), 21.0 - 10.0 * t1.sin(), 3.0, t1, t1 + std::f32::consts::PI, a); - pen.dot(12.0, 21.0, ink); - } - "ellipse" => { - let pts: Vec<(f32, f32)> = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| (12.0 + 9.5 * t.cos(), 12.0 + 5.5 * t.sin())).collect(); - pen.closed(&pts, a); - pen.dot(12.0, 12.0, ink); - pen.line(&[(12.0, 12.0), (21.5, 12.0)], ink); - } - "spline" | "spline_cv" => { - let c = [(3.0_f32, 18.0_f32), (8.0, 3.0), (16.0, 21.0), (21.0, 6.0)]; - let pts: Vec<(f32, f32)> = (0..=24) - .map(|i| i as f32 / 24.0) - .map(|t| { - let u = 1.0 - t; - let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; - (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) - }) - .collect(); - if name == "spline_cv" { - pen.line(&c, ink.gamma_multiply(0.6)); - for q in c { - pen.dot(q.0, q.1, ink); - } - } else { - for i in [0, 8, 16, 24] { - if let Some(q) = pts.get(i) { - pen.dot(q.0, q.1, ink); - } - } - } - pen.line(&pts, a); - } - "conic" => { - pen.line(&[(3.0, 20.0), (12.0, 3.0), (21.0, 20.0)], ink.gamma_multiply(0.6)); - let pts: Vec<(f32, f32)> = - (0..=20).map(|i| i as f32 / 20.0).map(|t| (3.0 + 18.0 * t, 20.0 - 4.0 * 0.78 * 17.0 * t * (1.0 - t))).collect(); - pen.line(&pts, a); - pen.dot(3.0, 20.0, ink); - pen.dot(21.0, 20.0, ink); - } - "text" => { - p.text(pen.at(12.0, 11.5), egui::Align2::CENTER_CENTER, "A", egui::FontId::proportional(17.0 * pen.s()), a); - pen.line(&[(4.0, 20.5), (20.0, 20.5)], ink); - } - "curvature_comb" => { - let f = |x: f32| 16.0 - 6.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin(); - let mut env = Vec::new(); - for i in 0..=8 { - let x = 3.0 + 18.0 * i as f32 / 8.0; - let k = 1.0 + 5.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin(); - pen.line(&[(x, f(x)), (x, f(x) - k)], a.gamma_multiply(0.7)); - env.push((x, f(x) - k)); - } - pen.line(&env, a); - let pts: Vec<(f32, f32)> = (0..=24).map(|i| 3.0 + 18.0 * i as f32 / 24.0).map(|x| (x, f(x))).collect(); - pen.line(&pts, ink); - } - "min_radius" => { - pen.line(&[(3.0, 20.0), (7.0, 9.0), (12.0, 5.5), (17.0, 9.0), (21.0, 20.0)], ink); - pen.circle(12.0, 10.5, 5.0, Color32::TRANSPARENT, a); - pen.arrow((12.0, 10.5), (15.5, 7.0), a); - pen.dot(12.0, 10.5, ink); - } - "iso_analysis" => { - pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (15.0, 19.0)], pen.fill, ink); - for k in 1..4 { - let t = k as f32 / 4.0; - pen.line(&[(3.0 + 6.0 * t, 17.0 - 12.0 * t), (15.0 + 6.0 * t, 19.0 - 12.0 * t)], a); - pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a); - } - } - "accessibility" => { - pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink); - pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT); - pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink); - pen.arrow((4.0, 2.0), (4.0, 13.0), a); - pen.arrow((20.0, 2.0), (20.0, 13.0), a); - } - "environment_map" => { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink))); - pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT); - pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight); - p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight); - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - } - "zebra" | "draft_analysis" | "curvature_map" => { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - match name { - "zebra" => { - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink))); - for k in 0..3 { - let y = 6.5 + k as f32 * 4.5; - let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt(); - pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT); - } - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - } - "draft_analysis" => { - pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink); - pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT); - pen.arrow((12.0, 16.0), (12.0, 1.5), a); - } - _ => { - p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink))); - pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT); - pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT); - } - } - } - "center_of_mass" => { - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); - let q = |a0: f32| { - let pts: Vec = std::iter::once(pen.at(12.0, 12.0)) - .chain( - (0..=8) - .map(|i| a0 + i as f32 / 8.0 * std::f32::consts::FRAC_PI_2) - .map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 - 8.0 * t.sin())), - ) - .collect(); - p.add(Shape::convex_polygon(pts, a, Stroke::NONE)); - }; - q(0.0); - q(std::f32::consts::PI); - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); - } - "sketch_fillet" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.arc(12.0, 12.0, 8.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 12.0), (12.0, 4.0)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - // The three chamfer types, for the Chamfer Type row of the corner tool window. Each - // draws the cut it makes: equal legs with a tick on each, two unequal legs, and one leg - // with the angle the cut leaves it at. - "sketch_chamfer_equal" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 12.0), (12.0, 4.0)], a); - pen.line(&[(2.5, 9.5), (5.5, 6.5)], a); - pen.line(&[(9.5, 2.5), (6.5, 5.5)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer_two" => { - pen.line(&[(4.0, 21.0), (4.0, 10.0)], ink); - pen.line(&[(18.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 10.0), (18.0, 4.0)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 10.0), pen.at(4.0, 4.0), pen.at(18.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer_angle" => { - pen.line(&[(4.0, 21.0), (4.0, 13.0)], ink); - pen.line(&[(14.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 13.0), (14.0, 4.0)], a); - pen.arc(4.0, 13.0, 6.0, -std::f32::consts::FRAC_PI_2, 0.73, a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 13.0), pen.at(4.0, 4.0), pen.at(14.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "offset" => { - pen.closed(&[(8.0, 9.0), (16.0, 9.0), (16.0, 15.0), (8.0, 15.0)], ink); - pen.line(&[(8.0, 4.0), (16.0, 4.0)], a); - pen.line(&[(21.0, 9.0), (21.0, 15.0)], a); - pen.line(&[(16.0, 20.0), (8.0, 20.0)], a); - pen.line(&[(3.0, 15.0), (3.0, 9.0)], a); - pen.arc(16.0, 9.0, 5.0, 0.0, std::f32::consts::FRAC_PI_2, a); - pen.arc(16.0, 15.0, 5.0, -std::f32::consts::FRAC_PI_2, 0.0, a); - pen.arc(8.0, 15.0, 5.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); - pen.arc(8.0, 9.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a); - } - // Offset mode icons, drawn with the house `dashed` helper. The source is the solid - // line and each result a dashed one. One Side has a single result; Two Sides has one - // each way at two different gaps; Symmetric has one each way at the same gap. - "offset_one_side" => { - pen.line(&[(8.0, 4.0), (8.0, 20.0)], ink); - pen.dashed(&[(18.0, 4.0), (18.0, 20.0)], a); - } - "offset_two_sides" => { - pen.line(&[(13.0, 4.0), (13.0, 20.0)], ink); - pen.dashed(&[(3.0, 4.0), (3.0, 20.0)], a); - pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); - } - "offset_symmetric" => { - pen.line(&[(12.0, 4.0), (12.0, 20.0)], ink); - pen.dashed(&[(4.0, 4.0), (4.0, 20.0)], a); - pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); - } - "trim" => { - pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); - pen.line(&[(3.0, 12.0), (12.0, 12.0)], ink); - p.add(Shape::dashed_line(&[pen.at(12.0, 12.0), pen.at(21.0, 12.0)], Stroke::new(pen.w, a), 2.0 * pen.s(), 1.6 * pen.s())); - pen.line(&[(15.0, 15.0), (19.0, 19.0)], a); - pen.line(&[(19.0, 15.0), (15.0, 19.0)], a); - } - "patch" => { - // A wire loop with a surface spanned across it. - pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink); - pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a); - } - "stitch" => { - // Two surface patches sewn along their shared edge. - pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink); - pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink); - for y in [6.0, 9.0, 12.0] { - pen.line(&[(10.0, y), (14.0, y + 1.5)], a); - } - } - "thicken" => { - // A wavy sheet (accent edges) grown downward into a slab, shaded like `iso_box`. - let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)]; - let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)]; - let d = f.map(|(x, y)| (x, y + 6.0)); - pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet); - pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85)); - pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink); - pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink); - pen.line(&[(17.0, 12.5), (17.0, 18.5)], ink); - pen.line(&[(21.4, 8.9), (21.4, 14.9)], ink); - pen.curve(d, ink); - pen.line(&[(6.4, 8.9), (2.0, 12.5)], ink); - pen.line(&[(21.4, 8.9), (17.0, 12.5)], a); - pen.curve(b, a); - pen.curve(f, a); - } - "extend" => { - pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink); - pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink); - pen.dot(10.0, 12.0, ink); - pen.arrow((10.0, 12.0), (19.0, 12.0), a); - } - "break" => { - pen.line(&[(3.0, 17.0), (10.5, 11.0)], ink); - pen.line(&[(13.5, 10.0), (21.0, 5.0)], a); - pen.dot(12.0, 10.5, a); - pen.line(&[(9.0, 5.0), (15.0, 16.0)], ink.gamma_multiply(0.5)); - } - "scale" => { - pen.closed(&[(3.0, 13.0), (11.0, 13.0), (11.0, 21.0), (3.0, 21.0)], ink); - pen.closed(&[(3.0, 3.0), (21.0, 3.0), (21.0, 21.0), (3.0, 21.0)], a); - pen.dot(3.0, 21.0, ink); - pen.arrow((11.0, 13.0), (18.0, 6.0), a); - } - "blend" => { - pen.line(&[(2.0, 19.0), (8.0, 15.0)], ink); - pen.line(&[(16.0, 9.0), (22.0, 5.0)], ink); - pen.curve([(8.0, 15.0), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)], a); - pen.dot(8.0, 15.0, ink); - pen.dot(16.0, 9.0, ink); - } - // Surface modelling: open sheets (no caps) in the sheet colours, the input or the result - // in the accent. - "surface_extrude" => { - // An open profile (accent) pulled up into a sheet. - let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)]; - let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)]; - pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet); - pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink); - pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink); - pen.curve(top, ink); - pen.curve(bottom, a); - pen.arrow((12.0, 8.5), (12.0, 2.5), a); - } - "surface_revolve" => { - // An open profile (accent) turned about the axis into a hollow band. - let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)]; - let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)]; - pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet); - let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16); - pen.poly(&front, ic.sheet, Color32::TRANSPARENT); - // A closed fill takes each point once: a repeated end point spikes `poly`'s edge. - let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32); - opening.pop(); - pen.poly(&opening, ic.sheet_light, ink); - pen.line(&front, ink); - pen.curve(left, ink); - pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink); - pen.line(&[(12.0, 20.2), (12.0, 22.5)], ink); - pen.curve(right, a); - } - "surface_sweep" => { - // A straight profile (accent) carried along a curved path: a ribbon. - let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)]; - let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)]; - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet); - pen.curve(near, ink); - pen.curve(far, ink); - pen.line(&[(4.0, 13.0), (4.0, 20.0)], a); - pen.line(&[(20.0, 9.0), (20.0, 16.0)], ink); - } - "surface_loft" => { - // Two open sections (accent) joined by a sheet. - let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)]; - let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)]; - pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet); - pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink); - pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink); - pen.curve(top, a); - pen.curve(bottom, a); - } - "surface_offset" => { - // A wavy sheet and its offset copy (dashed), the distance shown by the arrow. - let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)]; - let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)]; - pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet); - pen.curve(back, ink); - pen.curve(front, ink); - pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink); - pen.line(&[(21.0, 14.0), (18.0, 20.0)], ink); - pen.dashed(&bezier(back.map(|(x, y)| (x, y - 9.0)), 16), a); - pen.dashed(&bezier(front.map(|(x, y)| (x, y - 9.0)), 16), a); - pen.dashed(&[(6.0, 5.0), (3.0, 11.0)], a); - pen.dashed(&[(21.0, 5.0), (18.0, 11.0)], a); - pen.arrow((12.0, 17.0), (12.0, 8.5), a); - } - "ruled" => { - // A strip hanging off a block's edge (accent), ruled by straight lines. - pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink); - pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink); - pen.line(&[(15.0, 9.3), (15.0, 19.3)], a); - pen.line(&[(18.0, 6.7), (18.0, 16.7)], a); - pen.line(&[(12.0, 12.0), (12.0, 22.0)], a); - } - "boundary" => { - // Four boundary curves (accent) meeting at corners, the patch between them a Coons - // patch with its iso lines. - let (c00, c10, c11, c01) = ((4.0, 17.0), (10.0, 5.0), (20.0, 7.0), (17.0, 20.0)); - let left = [c00, (5.0, 12.0), (8.0, 8.0), c10]; - let top = [c10, (13.0, 4.0), (17.0, 4.0), c11]; - let right = [c01, (19.0, 16.0), (20.0, 11.0), c11]; - let bottom = [c00, (8.0, 20.0), (13.0, 21.0), c01]; - let n = 12; - let lerp = |p: (f32, f32), q: (f32, f32), s: f32| (p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s); - let (b, t) = (bezier(bottom, n), bezier(top, n)); - // Each row runs across the patch (u) at one height (v) up the left and right sides. - let rows: Vec> = bezier(left, n) - .into_iter() - .zip(bezier(right, n)) - .enumerate() - .map(|(j, (lv, rv))| { - let v = j as f32 / n as f32; - b.iter() - .zip(&t) - .enumerate() - .map(|(i, (bu, tu))| { - let u = i as f32 / n as f32; - let corners = lerp(lerp(c00, c01, u), lerp(c10, c11, u), v); - let (ruled_v, ruled_u) = (lerp(*bu, *tu, v), lerp(lv, rv, u)); - (ruled_v.0 + ruled_u.0 - corners.0, ruled_v.1 + ruled_u.1 - corners.1) - }) - .collect() - }) - .collect(); - for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) { - pen.band(lo, hi, ic.sheet); - } - for j in [n / 3, 2 * n / 3] { - if let Some(r) = rows.get(j) { - pen.line(r, ic.sheet_line); - } - pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::>(), ic.sheet_line); - } - for c in [left, top, right, bottom] { - pen.curve(c, a); - } - for (x, y) in [c00, c10, c11, c01] { - pen.dot(x, y, ink); - } - } - "surface_blend" => { - // Two separate sheets at different heights bridged by a smooth strip (accent) that - // leaves both edges tangent. - let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)]; - let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); - pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink); - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light); - pen.curve(far, a); - pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink); - pen.curve(near, a); - } - "untrim" => { - // A trimmed sheet whose hole (dashed, accent) is filled back in. - pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink); - let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32); - pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT); - pen.dashed(&hole, a); - } - "unstitch" => { - // Two sheets pulled apart from their shared edge. - pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink); - pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink); - pen.arrow((10.8, 11.0), (7.0, 11.0), a); - pen.arrow((13.2, 11.0), (17.0, 11.0), a); - } - "reverse_normal" => { - // A sheet with its old normal (faded) and the flipped one (accent). - pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink); - pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5)); - pen.arrow((12.0, 14.0), (12.0, 22.5), a); - } - "surface_fillet" => { - // A wall sheet and a floor sheet joined by a round strip (accent edges). - let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)]; - let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); - pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink); - pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink); - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light); - pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line); - pen.curve(far, a); - pen.curve(near, a); - } - "continuity" => { - // A straight curve joining a curved one (accent) at a point, with the curved one's - // curvature comb: teeth that grow as the curve tightens. - pen.line(&[(2.0, 17.0), (12.0, 13.0)], ink); - let c = [(12.0, 13.0), (16.5, 11.2), (20.0, 8.0), (21.0, 3.0)]; - let pts = bezier(c, 10); - let mut tips = Vec::new(); - for (i, ((x, y), (x1, y1))) in pts.iter().zip(pts.iter().skip(1)).enumerate() { - let len = (x1 - x).hypot(y1 - y).max(1e-6); - let h = 0.8 + 4.2 * i as f32 / 10.0; - let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h); - if i % 2 == 0 { - pen.line(&[(*x, *y), tip], ic.sheet_line); - } - tips.push(tip); - } - pen.line(&tips, ic.sheet_line); - pen.curve(c, a); - pen.dot(12.0, 13.0, a); - } - // Assembly: joints (accent: the joint itself). - "joint" => { - pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0); - pen.iso_box(12.0, 4.0, 7.0, 5.0, 5.0); - pen.line(&[(9.0, 15.0), (15.0, 9.0)], a); - pen.circle(12.0, 12.0, 2.6, pen.fill, a); - } - "as_built" => { - pen.iso_box(1.0, 10.0, 8.0, 5.0, 8.0); - pen.iso_box(10.0, 10.0, 8.0, 5.0, 8.0); - pen.circle(13.0, 15.0, 2.6, pen.fill, a); - } - "joint_origin" => { - pen.arrow((8.0, 17.0), (20.0, 17.0), ink); - pen.arrow((8.0, 17.0), (14.0, 11.0), ink); - pen.arrow((8.0, 17.0), (8.0, 3.0), a); - pen.circle(8.0, 17.0, 2.4, pen.fill, a); - } - "rigid_group" => { - pen.iso_box(3.0, 13.0, 5.0, 3.0, 4.0); - pen.iso_box(13.0, 13.0, 5.0, 3.0, 4.0); - pen.iso_box(8.0, 4.0, 5.0, 3.0, 4.0); - pen.closed(&[(2.0, 3.0), (22.0, 3.0), (22.0, 21.0), (2.0, 21.0)], a); - } - "drive" => { - pen.circle(12.0, 12.0, 4.0, pen.fill, ink); - pen.dot(12.0, 12.0, ink); - pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a); - pen.arrow((11.0, 20.4), (14.5, 20.3), a); - } - // A crank on its pivot and the closed path its far end draws (accent). - "trace" => { - pen.curve([(15.0, 13.0), (22.0, 13.0), (23.0, 3.0), (15.0, 3.0)], a); - pen.curve([(15.0, 3.0), (8.0, 3.0), (9.0, 13.0), (15.0, 13.0)], a); - pen.line(&[(5.0, 20.0), (15.0, 13.0)], ink); - pen.circle(5.0, 20.0, 2.4, pen.fill, ink); - pen.dot(15.0, 13.0, a); - } - "motion_link" => { - pen.circle(7.0, 14.0, 4.5, pen.fill, ink); - pen.circle(17.5, 9.0, 3.0, pen.fill, ink); - pen.line(&[(7.0, 14.0), (17.5, 9.0)], a); - pen.dot(7.0, 14.0, a); - pen.dot(17.5, 9.0, a); - } - // Couplings: the driven part in the accent. - "gear" => { - pen.circle(9.0, 14.0, 5.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(9.0, 14.0, 5.2, 6.8, 9, 0.0), ink); - pen.dot(9.0, 14.0, ink); - pen.circle(17.6, 7.4, 3.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(17.6, 7.4, 3.2, 4.6, 6, 0.3), a); - pen.dot(17.6, 7.4, a); - } - "rack_pinion" => { - pen.circle(12.0, 7.4, 4.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(12.0, 7.4, 4.2, 5.8, 8, 0.2), ink); - pen.dot(12.0, 7.4, ink); - let mut rack = vec![(2.0, 18.0)]; - for k in 0..5 { - let x = 2.5 + 4.0 * k as f32; - rack.extend([(x, 14.3), (x + 1.0, 12.8), (x + 2.2, 12.8), (x + 3.2, 14.3)]); - } - rack.extend([(22.0, 14.3), (22.0, 18.0)]); - pen.closed(&rack, a); - pen.arrow((7.0, 21.5), (17.5, 21.5), a); - } - "screw" => { - pen.poly(&[(9.5, 2.0), (14.5, 2.0), (14.5, 22.0), (9.5, 22.0)], pen.fill, ink); - for y in [5.0, 8.0, 17.0, 20.0] { - pen.line(&[(9.5, y), (14.5, y - 2.0)], ink); - } - pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a); - pen.arrow((21.0, 17.0), (21.0, 6.0), a); - } - "cam" => { - // A cam on its shaft, its lobe up under the follower it lifts. - let cam: Vec<(f32, f32)> = (0..36) - .map(|k| { - let t = std::f32::consts::TAU * k as f32 / 36.0; - let r = 5.0 + 3.0 * (0.5 + 0.5 * t.sin()).powi(3); - (11.0 + r * t.cos(), 16.0 - r * t.sin()) - }) - .collect(); - pen.poly(&cam, pen.fill, ink); - pen.dot(11.0, 16.0, ink); - pen.poly(&[(9.0, 2.0), (13.0, 2.0), (13.0, 8.0), (9.0, 8.0)], pen.fill, a); - pen.arrow((18.5, 9.5), (18.5, 2.5), a); - } - "interference" => { - pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink); - pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference); - pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink); - } - // Sheet metal: thin plates (fill), the made or changed part in the accent. - "flange" => { - pen.poly(&[(2.0, 17.0), (12.0, 13.0), (18.0, 16.0), (8.0, 20.0)], pen.fill, ink); - pen.poly(&[(12.0, 13.0), (12.0, 5.0), (18.0, 8.0), (18.0, 16.0)], pen.fill.gamma_multiply(0.8), a); - pen.arrow((20.5, 15.0), (20.5, 4.0), a); - } - "hem" => { - pen.line(&[(2.0, 16.0), (16.0, 16.0)], ink); - pen.line(&[(2.0, 13.0), (16.0, 13.0)], ink); - pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a); - pen.line(&[(16.0, 7.0), (8.0, 7.0)], a); - } - // Fastener: a socket head screw seen from the side. - "fastener" => { - pen.poly(&[(6.0, 3.0), (18.0, 3.0), (18.0, 9.0), (6.0, 9.0)], pen.fill, ink); - pen.line(&[(10.0, 3.0), (10.0, 6.0), (14.0, 6.0), (14.0, 3.0)], a); - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 21.0), (9.0, 21.0)], pen.fill.gamma_multiply(0.85), ink); - for y in [12.0, 15.0, 18.0] { - pen.line(&[(9.0, y), (15.0, y + 1.5)], ink); - } - } - "fold" => { - pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink); - pen.line(&[(12.0, 18.0), (19.0, 6.0)], a); - pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold); - pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a); - pen.arrow((18.0, 16.0), (17.0, 12.5), a); - } - "unfold" => { - pen.line(&[(3.0, 18.0), (12.0, 18.0), (12.0, 7.0)], ink); - pen.line(&[(12.0, 18.0), (22.0, 18.0)], a); - pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); - pen.arrow((17.5, 14.0), (19.0, 16.0), a); - } - "refold" => { - pen.line(&[(3.0, 18.0), (12.0, 18.0), (21.0, 18.0)], ink); - pen.line(&[(12.0, 18.0), (12.0, 6.0)], a); - pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); - pen.arrow((15.0, 11.5), (13.5, 10.5), a); - } - "flat" => { - pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); - for x in [9.0, 15.0] { - for k in 0..3 { - let y = 7.5 + k as f32 * 4.0; - pen.line(&[(x, y), (x, y + 2.2)], a); - } - } - } - "convert_sheet" => { - pen.iso_box(2.0, 9.0, 6.0, 4.0, 6.0); - pen.arrow((11.0, 12.0), (15.0, 12.0), a); - pen.poly(&[(15.0, 15.0), (21.0, 12.0), (23.0, 13.0), (17.0, 16.0)], pen.fill, a); - } - "sheet_rules" => { - pen.poly(&[(2.0, 15.0), (11.0, 11.0), (17.0, 14.0), (8.0, 18.0)], pen.fill, ink); - for y in [5.0, 9.0, 13.0] { - pen.line(&[(14.0, y), (22.0, y)], a); - } - } - // Plastic: a wall or plate (fill) with the feature in the accent. - "boss" => { - pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); - pen.poly(&[(9.0, 7.0), (15.0, 7.0), (15.0, 16.0), (9.0, 16.0)], pen.fill.gamma_multiply(0.85), a); - pen.circle(12.0, 7.0, 3.0, pen.fill, a); - pen.circle(12.0, 7.0, 1.2, ink, ink); - } - "lip" => { - pen.poly(&[(4.0, 21.0), (4.0, 9.0), (10.0, 9.0), (10.0, 21.0)], pen.fill, ink); - pen.poly(&[(4.0, 9.0), (4.0, 4.0), (7.0, 4.0), (7.0, 9.0)], pen.fill, a); - pen.poly(&[(14.0, 21.0), (14.0, 6.0), (20.0, 6.0), (20.0, 21.0)], pen.fill, ink); - pen.closed(&[(17.0, 6.0), (17.0, 11.0), (20.0, 11.0), (20.0, 6.0)], a); - } - "snap" => { - pen.poly(&[(2.0, 20.0), (2.0, 16.0), (22.0, 16.0), (22.0, 20.0)], pen.fill, ink); - pen.poly(&[(9.0, 16.0), (9.0, 4.0), (12.0, 4.0), (12.0, 16.0)], pen.fill, a); - pen.poly(&[(12.0, 4.0), (16.0, 8.0), (12.0, 8.0)], a, a); - } - "rest" => { - pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); - pen.poly(&[(7.0, 14.0), (13.0, 11.5), (17.0, 13.5), (11.0, 16.0)], a, a); - pen.line(&[(7.0, 14.0), (7.0, 11.5), (13.0, 9.0), (17.0, 11.0), (17.0, 13.5)], a); - } - "plastic_rule" => { - pen.circle(7.0, 8.0, 3.2, pen.fill, a); - pen.circle(11.0, 13.0, 3.2, pen.fill, a); - pen.circle(5.5, 16.0, 3.2, pen.fill, a); - for y in [6.0, 11.0, 16.0] { - pen.line(&[(15.0, y), (22.0, y)], ink); - } - } - "plastic_assign" => { - pen.iso_box(2.0, 12.0, 7.0, 5.0, 6.0); - pen.arrow((19.0, 6.0), (13.0, 11.0), a); - pen.circle(19.0, 6.0, 3.0, pen.fill, a); - } - // Appearance: a shaded ball with three paint dabs. - "appearance" => { - pen.circle(10.0, 13.0, 7.5, pen.fill, ink); - pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight); - pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink); - pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink); - pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink); - } - "warning" => { - pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink); - pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink); - pen.dot(12.0, 17.0, ink); - } - // A failed feature: a disc with a cross, a different shape from the warning's triangle, so - // it reads without colour (#972). Drawn in the caller's colours only. - "error" => { - pen.circle(12.0, 12.0, 10.0, pen.fill, ink); - pen.line(&[(8.0, 8.0), (16.0, 16.0)], ink); - pen.line(&[(16.0, 8.0), (8.0, 16.0)], ink); - } - _ => { - pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A debug dump of everything `paint` draws for `name`, for comparing two colour choices - /// without depending on epaint's internal shape fields. - fn drawn(name: &str, ink: Color32, fill: Color32, accent: Color32) -> String { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)); - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let out = ctx.run_ui(input, |ui| { - let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); - paint(ui.painter(), r, name, ink, fill, accent); - }); - format!("{:?}", out.shapes) - } - - /// Command icons not drawn yet (the fallback square) or drawn past their square, from before - /// this check. Remove a name when its icon is fixed; never add one. - const KNOWN_GAPS: &[&str] = - &["cylinder", "pipe", "offset_face", "thread", "boundary_fill", "coil", "rib", "emboss", "capture", "revert", "camera", "material"]; - - /// What is wrong with the icon `name`: not drawn (the fallback square), or drawn past its - /// square by more than a stroke width. - fn icon_problem(name: &str) -> Option { - if drawn(name, Color32::BLACK, Color32::WHITE, Color32::BLUE) == drawn("", Color32::BLACK, Color32::WHITE, Color32::BLUE) { - return Some("no icon drawn".into()); - } - let side = 48.0; - let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side)); - let ctx = egui::Context::default(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() }; - let out = ctx.run_ui(input, |ui| paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::BLUE)); - let used = out.shapes.iter().fold(Rect::NOTHING, |b, s| b.union(s.shape.visual_bounding_rect())); - (!r.expand(side / 24.0 * 1.4).contains_rect(used)).then(|| format!("drawn over {used:?}")) - } - - /// Every command's icon is drawn and stays inside its square. - #[test] - fn every_command_icon_is_drawn_inside_its_square() { - let bad: Vec = solidcraft_engine::command_specs() - .into_iter() - .filter(|c| !c.icon.is_empty() && !KNOWN_GAPS.contains(&c.icon)) - .filter_map(|c| icon_problem(c.icon).map(|why| format!("{} ({}): {why}", c.id, c.icon))) - .collect(); - assert!(bad.is_empty(), "{}", bad.join("\n")); - for gap in KNOWN_GAPS { - assert!(icon_problem(gap).is_some(), "`{gap}` is fixed now: take it off KNOWN_GAPS"); - } - } - - /// The Sketch Palette and the toolbar both rely on this contract. Construction and - /// centerline must track whatever accent colour the caller passes (both callers pass the - /// theme's construction orange) and must not change with `ink`. - #[test] - fn construction_and_centerline_icons_follow_the_callers_accent_only() { - for name in ["construction", "centerline", "infinite"] { - let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3)); - let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6)); - assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour"); - let other_ink = drawn(name, Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3)); - assert_eq!(base, other_ink, "{name} icon has no ink-coloured parts: ink alone must not change it"); - } - } - - /// Normal is a plain line type. It is never construction geometry. Its icon must never pick - /// up the accent colour. It can never render construction orange even if a caller passes it - /// by mistake. - #[test] - fn normal_icon_never_uses_the_accent_colour() { - let one_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3)); - let other_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6)); - assert_eq!(one_accent, other_accent, "the Normal icon must not change with the accent colour"); - let other_ink = drawn("normal", Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3)); - assert_ne!(one_accent, other_ink, "the Normal icon must change with ink"); - } - - /// The dashes (as their first and last point) and the dots (as centre and radius) `paint` - /// actually drew for `name`, at `side` pixels square: the Sketch Palette draws these three - /// icons down to about 18 px. - fn dashes_and_dots(name: &str, side: f32) -> (Vec<(Pos2, Pos2)>, Vec<(Pos2, f32)>) { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)); - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side)); - let out = ctx.run_ui(input, |ui| { - paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(232, 140, 48)); - }); - let dashes = out - .shapes - .iter() - .filter_map(|s| match &s.shape { - Shape::Path(p) => Some((*p.points.first()?, *p.points.last()?)), - _ => None, - }) - .collect(); - let dots = out.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect(); - (dashes, dots) - } - - /// Pins the dash count and the gap. A future tidy-up might add dashes back in. It might - /// narrow the gap to look more faithful to the ISO convention. Either change would flatten - /// the icon back into a near-solid line at the size the Sketch Palette actually draws it, - /// about 18 px square. That is the exact defect this fix addressed. - #[test] - fn construction_and_centerline_read_as_two_dashes_with_a_gap_wider_than_the_stroke() { - for (name, want_dots) in [("construction", 0), ("centerline", 1)] { - for side in [18.0, 26.0] { - let (dashes, dots) = dashes_and_dots(name, side); - assert_eq!(dashes.len(), 2, "{name} at {side}px must be exactly two dashes"); - assert_eq!(dots.len(), want_dots, "{name} at {side}px has the wrong number of dots"); - let (_, gap_start) = dashes[0]; - let (gap_end, _) = dashes[1]; - let gap = gap_start.distance(gap_end); - let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side))); - assert!( - gap > stroke * 2.0, - "{name} at {side}px: the gap of {gap:.1}px must exceed the {stroke:.1}px stroke by a wide margin, or the dashes read as one solid line" - ); - } - } - } - - /// Pins the shape of the Centerline dot. It must be large enough to read as a round mark - /// beside the stroke. It must sit clear of both dashes. The silhouette must stay line, dot, - /// line. - #[test] - fn centerline_dot_is_a_bold_mark_clear_of_both_dashes() { - for side in [18.0, 26.0] { - let (dashes, dots) = dashes_and_dots("centerline", side); - let (center, radius) = dots[0]; - let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side))); - assert!(radius > stroke * 1.2, "centerline dot at {side}px: radius {radius:.1}px must exceed the {stroke:.1}px stroke by a wide margin"); - for (a, b) in &dashes { - assert!(center.distance(*a) > radius, "centerline dot at {side}px must not touch a dash end"); - assert!(center.distance(*b) > radius, "centerline dot at {side}px must not touch a dash end"); - } - } - } -} diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 2236e5ff..46683450 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -1,6 +1,10 @@ //! Original light and dark palette values. Compiled by the UI theme boundary. use crate::theme::{AnalysisColors, IconColors, Tokens}; use egui::Color32; + +const fn icon_primary(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) +} impl Tokens { pub const fn light() -> Tokens { Tokens { @@ -13,7 +17,7 @@ impl Tokens { text_dim: Color32::from_rgb(110, 116, 128), accent: Color32::from_rgb(38, 120, 218), accent_soft: Color32::from_rgb(214, 230, 250), - sketch_accent: Color32::from_rgb(46, 150, 90), + sketch_accent: icon_primary(crate::icon_svg::SKETCH_PRIMARY[0]), error: Color32::from_rgb(214, 54, 54), warning: Color32::from_rgb(222, 150, 30), icon: Color32::from_rgb(70, 78, 92), @@ -39,7 +43,7 @@ impl Tokens { axis_y: Color32::from_rgb(0, 255, 0), axis_z: Color32::from_rgb(2, 2, 248), construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70), - construction_geom: Color32::from_rgb(232, 140, 48), + construction_geom: icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[0]), body: Color32::from_rgb(176, 186, 198), body_edge: Color32::from_rgb(40, 44, 52), manipulator: Color32::from_rgb(40, 110, 220), @@ -71,7 +75,7 @@ impl Tokens { profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70), glyph_bg: Color32::WHITE, glyph_edge: Color32::from_rgb(190, 110, 100), - glyph_text: Color32::from_rgb(150, 40, 35), + glyph_text: icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[0]), overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235), rubber_band: Color32::from_rgb(30, 120, 230), app_bar_text: Color32::from_rgb(230, 234, 242), @@ -132,7 +136,8 @@ impl Tokens { t.text_dim = Color32::from_rgb(148, 155, 168); t.accent = Color32::from_rgb(58, 136, 238); t.accent_soft = Color32::from_rgb(42, 70, 110); - t.sketch_accent = Color32::from_rgb(110, 200, 140); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_PRIMARY[1]); + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]); t.error = Color32::from_rgb(240, 100, 96); t.warning = Color32::from_rgb(236, 170, 70); t.icon = Color32::from_rgb(208, 214, 226); @@ -162,7 +167,7 @@ impl Tokens { t.subheading_bg = Color32::from_rgb(48, 55, 68); t.glyph_bg = Color32::from_rgb(44, 50, 60); t.glyph_edge = Color32::from_rgb(200, 130, 120); - t.glyph_text = Color32::from_rgb(240, 165, 155); + t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[1]); t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240); t.rubber_band = Color32::from_rgb(92, 160, 255); t.timeline_item = Color32::from_rgb(52, 58, 70); @@ -196,18 +201,19 @@ impl Tokens { t.icons.axes[0] = t.triad[0]; t.icons.axes[1] = t.triad[1]; t.icons.access = [good, Color32::from_rgb(217, 102, 13)]; + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]); t.icons.draft = [good, Color32::from_rgb(242, 230, 153)]; if dark { t.bend_up = Color32::from_rgb(110, 165, 255); t.bend_down = Color32::from_rgb(240, 180, 70); - t.sketch_accent = Color32::from_rgb(150, 225, 195); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[1]); } else { t.sketch_projected = Color32::from_rgb(205, 120, 215); t.sketch_locked = Color32::from_rgb(60, 150, 120); t.timeline_item_error = Color32::from_rgb(232, 196, 170); t.bend_up = Color32::from_rgb(30, 100, 210); t.bend_down = Color32::from_rgb(200, 120, 0); - t.sketch_accent = Color32::from_rgb(60, 150, 105); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[0]); } t } @@ -217,10 +223,11 @@ impl Tokens { let mut t = self; t.analysis.hatch = true; t.preview_sketch = Color32::from_rgb(125, 180, 105); + t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]); + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]); if dark { t.sketch_locked = Color32::from_rgb(140, 225, 110); t.box_crossing = Color32::from_rgb(240, 110, 170); - t.glyph_text = Color32::from_rgb(240, 180, 165); } else { t.sketch_locked = Color32::from_rgb(20, 165, 70); t.box_crossing = Color32::from_rgb(200, 60, 120); diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 607cd25d..b8f93bf2 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -13,16 +13,16 @@ use crate::params::{bad, bool_, str_}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), + CommandSpec::new("body.rename", "Rename Body", rename_body).icon("rename").params("body: its name, name: the new name (features that use the body follow)"), CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) .icon("plane") .params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"), CommandSpec::new("browser.group", "New Group", group) .icon("folder") .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), - CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"), - CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"), - CommandSpec::new("browser.move", "Move Browser Items", move_items) + CommandSpec::new("browser.ungroup", "Ungroup", ungroup).icon("folder").params("group: id (its items go back to the folder)"), + CommandSpec::new("browser.rename_group", "Rename Group", rename_group).icon("rename").params("group: id, name"), + CommandSpec::new("browser.move", "Move Browser Items", move_items).icon("reorder") .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), CommandSpec::new("document.units", "Change Design Units", document_units) .icon("settings") @@ -30,13 +30,13 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("view.save", "New Named View", view_save) .icon("camera") .params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"), - CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"), - CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"), - CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(), + CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"), + CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"), + CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(), CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params( "view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step", ), - CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), + CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params( "visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \ or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \ diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index eff00a90..cf924e89 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -12,8 +12,9 @@ use crate::params::{bad, string_list, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"), + CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"), CommandSpec::new("timeline.paste", "Paste Features", paste) + .icon("paste") .params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"), ]; diff --git a/crates/engine/src/cmd/compare.rs b/crates/engine/src/cmd/compare.rs index be888746..439198c1 100644 --- a/crates/engine/src/cmd/compare.rs +++ b/crates/engine/src/cmd/compare.rs @@ -12,7 +12,7 @@ use super::CommandSpec; use crate::params::bad; use crate::{Result, Sel, Session}; -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params( +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params( "a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \ surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \ more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \ diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 80a4c5ff..76733d61 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -29,8 +29,8 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies) .icon("component") .params("bodies: [names] → one new component per body, named after it"), - CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), - CommandSpec::new("component.list", "List Components", list).noundo().params( + CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), + CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params( "compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \ identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)", ), @@ -40,20 +40,20 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence) .icon("measure").noundo() .params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"), - CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"), - CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"), + CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"), + CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"), CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params( "component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \ move and take joints one by one → joints (the ids removed)", ), - CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"), - CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), - CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) + CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params("bodies: [names], component"), + CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"), + CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy") .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), - CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), - CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) + CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), + CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move") .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), - CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), + CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"), CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"), ]; diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index 8dd29104..c5e3d6ba 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -20,12 +20,14 @@ pub static COMMANDS: &[CommandSpec] = &[ "path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \ name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]", ), - CommandSpec::new("component.update_links", "Update Links", update_links).params( + CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params( "component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed", ), CommandSpec::new("component.break_link", "Break Link", break_link) + .icon("break") .params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"), CommandSpec::new("component.links", "List Links", list_links) + .icon("chain") .noundo() .params("→ linked components: component, name, path, file (resolved), found"), ]; diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 49835ff7..ca170e5a 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -14,17 +14,17 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("config.table", "Display Configuration Table", show) .at("SOLID", "CONFIGURE") - .icon("params") + .icon("configuration_table") .noundo() .params("→ columns (param: or suppress:), rows, the active row and whether the design still matches it"), - CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"), - CommandSpec::new("config.row", "Configuration Row", row).params( + CommandSpec::new("config.column", "Configuration Column", column).icon("configuration_table").params("param? | feature? | material? | appearance? (target name); delete?: bool"), + CommandSpec::new("config.row", "Configuration Row", row).icon("configuration_table").params( "name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool", ), - CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"), - CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"), - CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), - CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"), + CommandSpec::new("config.activate", "Activate Configuration", activate).icon("activate").params("row: name — sets its parameter values and suppressions"), + CommandSpec::new("config.preview", "Preview Configuration", preview).icon("eye").noundo().params("row: name — validates a rebuild without changing the design or history"), + CommandSpec::new("config.rule", "Configuration Rule", rule).icon("configuration_table").params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), + CommandSpec::new("config.rules", "Configuration Rules", show).icon("list").noundo().params("ordered typed rules, rows and active state"), ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index 460cbbdd..4c42c43f 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -21,69 +21,69 @@ const REF: &str = pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_through_cylinder") .params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"), CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_normal_to_face") .params("face: ref; point: ref (the axis goes through it, square to the face); name?"), CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_two_planes") .params("a, b: plane or planar face refs; name?"), CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_two_points") .params("a, b: point refs (vertices, sketch points, points); name?"), CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_edge") .params("edge: ref (a straight edge or sketch line); name?"), CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_vertex") .params("point: ref (vertex, sketch point, point); name?"), CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_two_edges") .params("a, b: edge or line refs (where they meet, or come closest); name?"), CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_three_planes") .params("a, b, c: plane or planar face refs; name?"), CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_center") .params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"), CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_edge_plane") .params("edge: edge or line ref; plane: plane or planar face ref; name?"), CommandSpec::new("construct.point.along_path", "Point Along Path", point_along) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_along_path") .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"), CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_midplane") .params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"), CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_two_edges") .params("a, b: straight edge or line refs in one plane; name?"), CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_three_points") .params("a, b, c: point refs; name?"), CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_tangent") .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"), CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_along_path") .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"), - CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params( + CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params( "plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane", ), ]; diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs index 716b3bd8..25a4756d 100644 --- a/crates/engine/src/cmd/diagnostics.rs +++ b/crates/engine/src/cmd/diagnostics.rs @@ -8,20 +8,21 @@ use crate::params::{bad, str_}; use crate::{EngineError, Result, Session, crash}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).noundo().params( + CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).icon("bug_report").noundo().params( "limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \ command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \ Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay `", ), - CommandSpec::new("diagnostics.crash_report", "Crash Report", read).noundo().params( + CommandSpec::new("diagnostics.crash_report", "Crash Report", read).icon("bug_report").noundo().params( "id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \ crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \ (`solidcraft-cli run --in `)", ), CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear) + .icon("delete") .noundo() .params("id?: one report (default: all). Returns {deleted}"), - CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).noundo().enabled(crash_test_enabled).params( + CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params( "mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \ the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \ at the next start). Only when the SOLIDCRAFT_CRASH_TEST environment variable is set", diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ac6a07c..58e33c55 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -12,11 +12,11 @@ use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(), CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(), - CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint) + CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).icon("checkpoint") .noundo() .doc_only() .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"), - CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint) + CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).icon("restore") .noundo() .doc_only() .params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"), @@ -26,48 +26,48 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("delete") .key("Delete") .params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"), - CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"), + CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"), CommandSpec::new("timeline.group", "New Feature Folder", group) .icon("folder") .doc_only() .params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"), - CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup) + CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder") .doc_only() .params("group: id | groups: [ids] (features remain in the timeline)"), - CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"), - CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group) + CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name"), + CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder") .doc_only() .params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"), - CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params( + CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params( "features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block", ), - CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), - CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback) + CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active (omit = end)"), + CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback") .params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"), - CommandSpec::new("timeline.reorder", "Reorder Feature", reorder) + CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder") .params("feature: id|name, position: its new index; refused when a feature that worked would fail"), - CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params( + CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params( "features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail", ), - CommandSpec::new("timeline.redefine", "Edit Feature", redefine) + CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit") .params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"), - CommandSpec::new("timeline.dependents", "Feature Dependents", dependents) + CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph") .noundo() .params("feature: id|name → features deleted with it, and features that would fail without it"), - CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), - CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params( + CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params("feature: id|name, suppressed?: bool (default toggles)"), + CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params( "feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \ e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \ expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused", ), - CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params( + CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params( "feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \ inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning", ), - CommandSpec::new("select.set", "Select", select_set) + CommandSpec::new("select.set", "Select", select_set).icon("select") .noundo() .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), - CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(), + CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(), ]; fn undo(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 025b2c35..15763a5d 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -48,7 +48,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"), CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path) .at("SOLID", "CREATE") - .icon("pattern_rect") + .icon("pattern_path") .params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"), CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (origin coordinates may be length expressions; the mirror follows them, or a construction plane)"), CommandSpec::new("solid.hole", "Hole", hole) @@ -62,7 +62,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid) .at("SOLID", "CREATE") - .icon("box") + .icon("bounding_solid") .params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"), CommandSpec::new("solid.scale", "Scale", scale) .at("SOLID", "MODIFY") @@ -70,7 +70,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"), CommandSpec::new("face.delete", "Delete Face", delete_face) .at("SOLID", "MODIFY") - .icon("delete") + .icon("delete_face") .params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"), CommandSpec::new("solid.offset_face", "Offset Face", offset_face) .at("SOLID", "MODIFY") @@ -94,17 +94,17 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"), CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_angle") .params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"), CommandSpec::new("solid.split_face", "Split Face", split_face) .at("SOLID", "MODIFY") - .icon("split") + .icon("split_face") .params( "faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)", ), CommandSpec::new("solid.split_body", "Split Body", split_body) .at("SOLID", "MODIFY") - .icon("split") + .icon("split_body") .params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"), CommandSpec::new("solid.press_pull", "Press Pull", press_pull) .at("SOLID", "MODIFY") diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 055b56c9..7a255925 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -28,7 +28,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"), CommandSpec::new("solid.web", "Web", web) .at("SOLID", "CREATE") - .icon("rib") + .icon("web") .params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"), CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") @@ -36,11 +36,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") - .icon("offset_face") + .icon("replace_face") .params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"), CommandSpec::new("solid.align", "Align", align) .at("SOLID", "MODIFY") - .icon("move") + .icon("align") .params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"), CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"), ]; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 35430742..cc137984 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -38,23 +38,23 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("save") .noundo() .params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"), - CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list) + CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore") .noundo() .params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"), - CommandSpec::new("doc.recover", "Recover Design", recover) + CommandSpec::new("doc.recover", "Recover Design", recover).icon("restore") .noundo() .params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"), - CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"), + CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"), CommandSpec::new("file.export", "Export", export) .icon("export") .noundo() .params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10)"), CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh) .at("MESH", "MODIFY") - .icon("export") + .icon("mesh_export") .noundo() .params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id, name or program path from file.slicers (open the saved file there)"), - CommandSpec::new("file.slicers", "Installed Slicers", slicers) + CommandSpec::new("file.slicers", "Installed Slicers", slicers).icon("export") .noundo() .params("→ the slicers found on this machine: [{id, name, icon?}]; pass an id or name as file.save_mesh's `slicer`"), ]; diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 410c6c43..1d215957 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -14,14 +14,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("I") .noundo() .params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"), - CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"), - CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"), - CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"), - CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params( + CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements)"), + CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"), + CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"), + CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params( "body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \ plane_normal, point (a point on the face that picks it), cylinder? ({origin, axis, radius} of a cylindrical face)", ), - CommandSpec::new("model.threads", "List Threads", model_threads).noundo(), + CommandSpec::new("model.threads", "List Threads", model_threads).icon("inspect").noundo(), CommandSpec::new("material.assign", "Physical Material", physical_material) .at("SOLID", "MODIFY") .icon("material") @@ -31,17 +31,17 @@ pub static COMMANDS: &[CommandSpec] = &[ appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \ pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material", ), - CommandSpec::new("appearance.library", "Appearance Library", appearance_library) + CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library") .noundo() .params("→ the built-in appearances (name, colour, opacity)"), - CommandSpec::new("appearance.list", "List Appearances", appearance_list) + CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list") .noundo() .params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"), - CommandSpec::new("material.list", "List Materials", material_list) + CommandSpec::new("material.list", "List Materials", material_list).icon("list") .noundo() .doc_only() .params("→ the built-in materials, then the design's own (user: true, with their look)"), - CommandSpec::new("engine.commands", "List Commands", commands).noundo(), + CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(), ]; pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value { diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 91b55ed3..6e94d472 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[ reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \ the occurrence named)", ), - CommandSpec::new("joint.reach", "Reach Point", reach).params( + CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params( "occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move", ), CommandSpec::new("joint.motion_link", "Motion Link", motion_link) @@ -64,14 +64,14 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params( "a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is", ), - CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), - CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), - CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"), - CommandSpec::new("joint.list", "List Joints", list).noundo().params( + CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), + CommandSpec::new("joint.edit", "Edit Joint", edit).icon("edit").params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), + CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"), + CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params( "→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \ (joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)", ), - CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"), + CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"), CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params( "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", ), diff --git a/crates/engine/src/cmd/libraries.rs b/crates/engine/src/cmd/libraries.rs index 585d5f10..ad64fef6 100644 --- a/crates/engine/src/cmd/libraries.rs +++ b/crates/engine/src/cmd/libraries.rs @@ -6,11 +6,11 @@ use serde_json::{Value, json}; use solidcraft_io::catalog::{Catalog, Definition, Provenance}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("library.inspect", "Inspect Local Library", inspect).noundo().doc_only() + CommandSpec::new("library.inspect", "Inspect Local Library", inspect).icon("inspect").noundo().doc_only() .params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"), - CommandSpec::new("library.export", "Export Design Library", export).noundo().doc_only() + CommandSpec::new("library.export", "Export Design Library", export).icon("export").noundo().doc_only() .params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"), - CommandSpec::new("library.apply", "Apply Local Library Entry", apply).enabled(super::not_in_sketch) + CommandSpec::new("library.apply", "Apply Local Library Entry", apply).icon("import").enabled(super::not_in_sketch) .params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"), ]; diff --git a/crates/engine/src/cmd/materials.rs b/crates/engine/src/cmd/materials.rs index c96376a2..0c27d46b 100644 --- a/crates/engine/src/cmd/materials.rs +++ b/crates/engine/src/cmd/materials.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params( "material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look", ), - CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params( + CommandSpec::new("material.delete", "Delete Physical Material", delete).icon("delete").enabled(has_user_materials).params( "material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)", ), ]; diff --git a/crates/engine/src/cmd/metadata.rs b/crates/engine/src/cmd/metadata.rs index 451d7949..de1b7809 100644 --- a/crates/engine/src/cmd/metadata.rs +++ b/crates/engine/src/cmd/metadata.rs @@ -22,6 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[ reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]", ), CommandSpec::new("document.metadata.remove", "Remove Design Property", remove) + .icon("delete") .doc_only() .params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"), ]; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 786089d7..03f01798 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -30,35 +30,41 @@ macro_rules! trace_doc { } pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("contact_sets").params( "enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other", ), CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all) .at("SOLID", "ASSEMBLE") - .icon("joint") + .icon("contact_all") .params("every occurrence takes part in contact (one set of all)"), - CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"), + CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("contact_off").params("contact off"), CommandSpec::new("contact.create", "New Contact Set", new_set) .at("SOLID", "ASSEMBLE") - .icon("joint") + .icon("contact_new") .params("occurrences: [ids or names] (two or more); name?"), - CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), - CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"), - CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"), - CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("contact.edit", "Edit Contact Set", edit_set) + .icon("edit") + .params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), + CommandSpec::new("contact.list", "List Contact Sets", list_sets).icon("list").noundo().doc_only().params("→ sets, enabled, all"), + CommandSpec::new("contact.check", "Check Contact", check) + .icon("interference") + .noundo() + .params("→ pairs in contact sets that pass through each other now"), + CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("play").params( "name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \ (values: degrees for angles, mm for distances, or expressions)", ), - CommandSpec::new("motion.play", "Play Motion Study", play).params( + CommandSpec::new("motion.play", "Play Motion Study", play).icon("play").params( "study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them); quiet?: bool \ (positions of only the occurrences that moved) → values: [{joint, values: [{name, value, unit}]}], positions, moved: whether \ any occurrence moved, unmoved: [{joint, name, reason}] (keyed joints that can move nothing, such as a chain joined to \ nothing grounded)", ), CommandSpec::new("motion.export", "Export Motion Study", export) + .icon("export") .noundo() .params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"), - CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).noundo().params( + CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).icon("export").noundo().params( "study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)", ), CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!( @@ -66,24 +72,25 @@ pub static COMMANDS: &[CommandSpec] = &[ " → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \ ends there), stopped_by_loop, stopped_by_contact (the design is not changed)" )), - CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).params(concat!( + CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).icon("trace").params(concat!( trace_doc!(), "; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \ on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points" )), - CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).noundo().params(analysis::PLOT_DOC), - CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).noundo().params(analysis::INTERFERENCE_DOC), - CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(), - CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"), - CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC), + CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).icon("interference").noundo().params(analysis::INTERFERENCE_DOC), + CommandSpec::new("motion.list", "List Motion Studies", list_studies).icon("list").noundo(), + CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).icon("delete").params("study"), + CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").params( "name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \ moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)", ), CommandSpec::new("explode.show", "Show Exploded View", explode_show) + .icon("explode") .noundo() .params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"), - CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(), - CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"), + CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(), + CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"), ]; const MAX_ITEMS: usize = 10_000; diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 47756fe7..daa74cbc 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -15,34 +15,44 @@ pub static COMMANDS: &[CommandSpec] = &[ favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \ restored: features whose earlier result for the same inputs was reused (e.g. a value set back)", ), - CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params( + CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params( "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ source, feature/input, comment, favorite, uses, used_by, error) and cycles", ), - CommandSpec::new("parameters.add", "Add User Parameter", add).params("name, expression, unit?, comment?, favorite?: bool"), + CommandSpec::new("parameters.add", "Add User Parameter", add).icon("new").params("name, expression, unit?, comment?, favorite?: bool"), CommandSpec::new("parameters.rename", "Rename Parameter", rename) + .icon("rename") .params("name, new_name (expressions, feature inputs and dimensions that use it follow)"), - CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"), - CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"), + CommandSpec::new("parameters.delete", "Delete Parameter", delete) + .icon("delete") + .params("name (refused while something uses it; the error lists the users)"), + CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).icon("star").params("name, favorite?: bool (default true)"), CommandSpec::new("parameters.move", "Move Parameter", move_param) + .icon("reorder") .params("name, before | after: another user parameter; the user parameter is listed just before or after it"), - CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"), + CommandSpec::new("parameters.comment", "Comment Parameter", comment).icon("text").params("name, comment"), CommandSpec::new("parameters.users", "Parameter Users", users) + .icon("graph") .noundo() .params("name → users (parameters, feature inputs, dimensions) and the features it drives"), CommandSpec::new("parameters.graph", "Parameter Graph", graph) + .icon("graph") .noundo() .params("→ nodes, edges [user, used] (users include Feature:), cycles, errors"), CommandSpec::new("parameters.export", "Export Parameters", export) + .icon("export") .noundo() .params("format?: csv|json (default csv, or from the path), path?: write to a file → text"), CommandSpec::new("parameters.import", "Import Parameters", import) + .icon("import") .params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"), CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate) + .icon("params") .noundo() .doc_only() .params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"), CommandSpec::new("parameters.complete", "Complete Expression", complete) + .icon("params") .noundo() .doc_only() .params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"), diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index b0a60e4b..ffdb3e09 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -15,7 +15,7 @@ use crate::params::{bad, num, str_, vec3}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("parts.library", "Standard Parts", library) + CommandSpec::new("parts.library", "Standard Parts", library).icon("library") .noundo() .params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"), CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params( @@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[ length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?", ), - CommandSpec::new("parts.resize", "Change Part Size", resize) + CommandSpec::new("parts.resize", "Change Part Size", resize).icon("scale") .params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"), ]; diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index e825ab75..b6012139 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params( "face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?", ), - CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params( + CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap_fit").params( "position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?", ), CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params( @@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params( "bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults", ), - CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), + CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).icon("plastic_rule").noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), ]; fn rule_json(s: &Session, r: &PlasticRule) -> Value { diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs index 301cd5dd..e516b985 100644 --- a/crates/engine/src/cmd/render.rs +++ b/crates/engine/src/cmd/render.rs @@ -13,27 +13,27 @@ use crate::{Result, Session}; mod jobs; pub static COMMANDS: &[CommandSpec] = &[ -CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only() +CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only() .params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"), -CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box") +CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("render_image") .params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"), -CommandSpec::new("render.status", "Render Progress", jobs::status).noundo().doc_only() +CommandSpec::new("render.status", "Render Progress", jobs::status).icon("list").noundo().doc_only() .params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."), -CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only() +CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).icon("delete").noundo().doc_only() .params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."), -CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only() +CommandSpec::new("render.export", "Export Render Image", jobs::export).icon("export").noundo().doc_only() .params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"), -CommandSpec::new("render.backends", "Render Backends", backends) +CommandSpec::new("render.backends", "Render Backends", backends).icon("list") .noundo() .doc_only() .params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"), -CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo().doc_only() +CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only() .params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"), -CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).doc_only() +CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only() .params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background). Creates from defaults, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"), -CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("box") +CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene") .params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"), -CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).noundo().doc_only() +CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).icon("camera").noundo().doc_only() .params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."), ]; diff --git a/crates/engine/src/cmd/script.rs b/crates/engine/src/cmd/script.rs index 768d4f54..5eb55b50 100644 --- a/crates/engine/src/cmd/script.rs +++ b/crates/engine/src/cmd/script.rs @@ -14,12 +14,12 @@ use crate::params::{bad, str_}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("timeline.add", "Add Recorded Feature", add).params( + CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params( "feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; id and name are kept when free, \ so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \ dimensions use); the feature goes at the marker → feature, name, error, warning", ), - CommandSpec::new("timeline.export_script", "Export Command Script", export_script).noundo().doc_only().params( + CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params( "path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \ steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \ are not supported yet", diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 630a028d..8e0653e7 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[ Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \ and so is the corner relief on a base or contour flange", ), - CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params( + CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("perforate").params( "diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)", ), CommandSpec::new("sheet.hem", "Hem", hem) @@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[ `corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; default notch); \ active?: bool; delete?: bool", ), - CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"), + CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"), CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern) .at("SHEET METAL", "CREATE") .icon("flat") @@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params( "body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table", ), - CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) + CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf).icon("dxf") .noundo() .params( "body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those), \ @@ -77,7 +77,7 @@ pub static COMMANDS: &[CommandSpec] = &[ → DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \ POCKET__Z_TO__MM (one per side and depth) and PERFORATION; inner contours come before the outline", ), - CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table) + CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable") .noundo() .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"), ]; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 95cf367e..504f1838 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("L") .enabled(in_sketch) .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())), - CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) + CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension") .enabled(in_sketch) .params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) @@ -91,12 +91,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed) .at("SKETCH", "CREATE") - .icon("polygon") + .icon("polygon_circumscribed") .enabled(in_sketch) .params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge) .at("SKETCH", "CREATE") - .icon("polygon") + .icon("polygon_edge") .enabled(in_sketch) .params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c) @@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall) .at("SKETCH", "CREATE") - .icon("slot") + .icon("slot_overall") .enabled(in_sketch) .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())), @@ -174,27 +174,27 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("X") .enabled(in_sketch) .params("curves: [ids], value?: bool (default: construction unless all of them already are)"), - CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete) + CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete") .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), - CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), - CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params( + CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"), + CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params( "entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag", ), - CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params( + CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params( "constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom", ), - CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text) + CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit") .enabled(in_sketch) .params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"), CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params( "sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all", ), - CommandSpec::new("sketch.chain", "Chain", chain_cmd) + CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain") .enabled(in_sketch) .noundo() .params("curve: id: the curves joined to it end to end (what a double-click selects)"), - CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd) + CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute") .enabled(in_sketch) .noundo() .params("reports the solver status and degrees of freedom"), diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index 07f865c5..d6202511 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -12,17 +12,20 @@ use crate::params::{bad, req_vec3, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.line3d", "3D Line", line3d).enabled(in_sketch).params(concat!( + CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch).params(concat!( "points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)", created_doc!() )), CommandSpec::new("sketch.spline3d", "3D Spline", spline3d) + .icon("spline") .enabled(in_sketch) .params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())), CommandSpec::new("sketch.point3d", "3D Point", point3d) + .icon("point") .enabled(in_sketch) .params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())), CommandSpec::new("sketch.move3d", "Move 3D Point", move3d) + .icon("sketch_move") .enabled(in_sketch) .params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"), ]; diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index b884e784..08f16be3 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -18,9 +18,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("decal") .params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"), CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).at("SKETCH", "INSERT").icon("canvas").params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"), - CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate) + CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate).icon("measure") .params("canvas: id or name; a, b: two points on the canvas plane [x,y]; distance: their true distance (mm): scales the image about a"), - CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).params("canvas: id or name"), + CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).icon("delete").params("canvas: id or name"), ]; fn image_bytes(p: &Value, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs index 364f1851..fb9c4d19 100644 --- a/crates/engine/src/cmd/sketch_clipboard.rs +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -17,14 +17,14 @@ use crate::params::{bad, string_list, vec2}; use crate::{Result, Sel, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy) + CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy).icon("copy") .noundo() .enabled(in_sketch) .params("entities?: [curve or point ids] (default: the selected sketch geometry); the constraints and dimensions among them come along"), - CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut) + CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut).icon("cut") .enabled(in_sketch) .params("entities?: [curve or point ids] (default: the selected sketch geometry): copies them as sketch.copy does, then deletes them (one undo step)"), - CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).enabled(in_sketch).params( + CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).icon("paste").enabled(in_sketch).params( "at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)", ), ]; diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 260c8cfb..862dcd53 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -41,14 +41,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("constrainer") .enabled(in_sketch) .params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"), - CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven) + CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension") .enabled(in_sketch) .params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"), - CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs) + CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye") .enabled(in_sketch) .noundo() .params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"), - CommandSpec::new("sketch.snap", "Snap Inference", snap) + CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap") .enabled(in_sketch) .noundo() .params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"), diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 7b17fe4d..fbc538b1 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -47,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())), CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point) .at("SKETCH", "CREATE") - .icon("slot") + .icon("slot_center") .enabled(in_sketch) .params(concat!("center: slot centre, end: one arc centre, width", created_doc!())), CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three) @@ -57,7 +57,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("start, through, end: points on the centre arc, width", created_doc!())), CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center) .at("SKETCH", "CREATE") - .icon("arc_slot") + .icon("arc_slot_center") .enabled(in_sketch) .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())), CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!( @@ -97,16 +97,16 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("centerline") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), - CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line) + CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"), - CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!( + CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!( "text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \ font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ", text_style_doc!(), created_doc!() )), - CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) + CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit") .enabled(in_sketch) .live(edit_text_live) .params(concat!( diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 785c9ec8..83632df1 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -15,13 +15,13 @@ use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf) .at("SKETCH", "INSERT") - .icon("dxf") + .icon("sketch_dxf") .params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg) .at("SKETCH", "INSERT") .icon("svg") .params(concat!("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), - CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf) + CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf") .noundo() .params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"), ]; diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 43898ad9..cd9eeaf2 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -31,12 +31,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("point | a, b: a corner of two lines; distance", created_doc!())), CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle) .at("SKETCH", "MODIFY") - .icon("sketch_chamfer") + .icon("sketch_chamfer_angle") .enabled(in_sketch) .params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())), CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two) .at("SKETCH", "MODIFY") - .icon("sketch_chamfer") + .icon("sketch_chamfer_two_distances") .enabled(in_sketch) .params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())), CommandSpec::new("sketch.offset", "Offset", offset) @@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[ "distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions", created_doc!() )), - CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) + CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break") .enabled(in_sketch) .params("curve: derived offset curve id (retains geometry and makes its points editable)"), CommandSpec::new("sketch.trim", "Trim", trim) @@ -66,17 +66,17 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"), CommandSpec::new("sketch.break", "Break", break_cmd) .at("SKETCH", "MODIFY") - .icon("break") + .icon("sketch_break") .enabled(in_sketch) .params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())), CommandSpec::new("sketch.scale", "Sketch Scale", scale) .at("SKETCH", "MODIFY") - .icon("scale") + .icon("sketch_scale") .enabled(in_sketch) .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) .at("SKETCH", "MODIFY") - .icon("move") + .icon("sketch_move") .enabled(in_sketch) .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index f92adc81..7d4649c7 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("fit_section") .enabled(in_sketch) .params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())), - CommandSpec::new("sketch.break_link", "Break Link", break_link) + CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break") .enabled(in_sketch) .params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"), - CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project) + CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project") .noundo() .params("value?: bool (default toggles): project a face's edges when a sketch is created on it"), ]; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index bde3ffa2..56c45a77 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -18,23 +18,23 @@ use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.patch", "Patch", patch) .at("SURFACE", "CREATE") - .icon("patch") + .icon("surface_patch") .params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"), CommandSpec::new("surface.stitch", "Stitch", stitch) .at("SURFACE", "MODIFY") - .icon("stitch") + .icon("surface_stitch") .params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"), CommandSpec::new("surface.thicken", "Thicken", thicken) .at("SURFACE", "CREATE") - .icon("thicken") + .icon("surface_thicken") .params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"), CommandSpec::new("surface.extend", "Extend", extend) .at("SURFACE", "MODIFY") - .icon("extend") + .icon("surface_extend") .params("body: surface name; edges: [[x,y,z]] (points on boundary edges to push out); distance: expr (unless up_to); mode?: natural (the surface itself continued: exact for planes and surfaces of revolution, a B-spline's end spans continued; the default) | tangent (a ruled run-off face along each edge, G1) | curvature (a run-off face keeping the surface's curvature across the edge, G2); up_to?: plane (XY|XZ|YZ|plane name|{origin, normal}) | {body, point} (a face, its surface extended) | \"bounds\" (natural: back to the surface's own bounds, undoing a trim); neighbours?: bool (default true: the edges next to an extended edge are lengthened to meet it; false: the extension's sides run square to the edge)"), CommandSpec::new("surface.trim", "Trim", trim) .at("SURFACE", "MODIFY") - .icon("trim") + .icon("surface_trim") .params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"), CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude) .at("SURFACE", "CREATE") @@ -58,7 +58,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"), CommandSpec::new("surface.ruled", "Ruled Surface", ruled) .at("SURFACE", "CREATE") - .icon("ruled") + .icon("surface_ruled") .params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"), CommandSpec::new("surface.blend", "Blend Surface", blend) .at("SURFACE", "CREATE") @@ -66,19 +66,19 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"), CommandSpec::new("surface.untrim", "Untrim", untrim) .at("SURFACE", "MODIFY") - .icon("untrim") + .icon("surface_untrim") .params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"), CommandSpec::new("surface.unstitch", "Unstitch", unstitch) .at("SURFACE", "MODIFY") - .icon("unstitch") + .icon("surface_unstitch") .params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"), CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal) .at("SURFACE", "MODIFY") - .icon("reverse_normal") + .icon("surface_reverse_normal") .params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"), CommandSpec::new("surface.fair", "Fair Surface", fair) .at("SURFACE", "MODIFY") - .icon("spline_cv") + .icon("surface_fair") .params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"), planned!("surface.fillet", "Surface Fillet", "769") .at("SURFACE", "MODIFY") diff --git a/crates/engine/src/cmd/surface_boundary.rs b/crates/engine/src/cmd/surface_boundary.rs index 6bbc3f1a..79532302 100644 --- a/crates/engine/src/cmd/surface_boundary.rs +++ b/crates/engine/src/cmd/surface_boundary.rs @@ -21,7 +21,7 @@ const MAX_SAMPLES: usize = 512; pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary) .at("SURFACE", "CREATE") - .icon("boundary") + .icon("surface_boundary") .params( "sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)", )]; diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 48a06a6b..229e3bee 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -558,6 +558,7 @@ fn blend_corners_where_edges_meet_on_a_curved_wall() { fn a_boundary_found_across_a_seam_is_not_used() { let r = fuzz::run(20, 8); assert_eq!(r.outcome.kind, "ok", "seed 20: {:?}\n{}", r.outcome, r.script()); + assert!(r.steps.iter().any(|s| s.kind == "circular pattern"), "the pattern must build, not be skipped as unsupported: {:?}", r.unsupported); } /// Seed 33 ends in a fillet on its second body (#854's corner blends build it differently from diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index f84c768b..57f266ee 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -300,6 +300,14 @@ thread_local! { /// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`, /// whichever way it was made, or none where a step it took does not report one. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { + // Earlier coarse bores can leave a junction a later pattern copy cannot intersect. + // Resolve conical-cut junctions from the first copy, while the operands are unchanged. + if op == BoolOp::Cut && !APART.with(|c| c.get()) && conical_cut_junction(a, b) { + let refined = |a: &Body, b: &Body, op| boolean_whole_with_refinement(a, b, op, true); + if let Ok(result) = boolean_via(a, b, op, &refined) { + return Ok(result); + } + } let r = boolean_via(a, b, op, &boolean_whole); if r.is_ok() || !crate::tangent_cylinders::needed(a, b) { return r; @@ -312,6 +320,19 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { boolean_via(a, b, op, &split).or(r) } +/// A conical cutter meeting a body with an earlier conical cut needs finer tracing at +/// their junctions. Guard the analytic recognition as well as the Boolean itself. +fn conical_cut_junction(a: &Body, b: &Body) -> bool { + guard("conical cut junction", || { + let conical = |body: &Body| { + let tol = (body.size() * 1e-5).max(1e-7); + body.solid.face_iter().any(|face| matches!(crate::offset::surf_of(face, tol), Some(crate::offset::Surf::Cone { .. }))) + }; + Ok(conical(b) && conical(a)) + }) + .unwrap_or(false) +} + /// [`boolean`], the boolean of the whole bodies taken by `whole`. fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Result> { let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| whole(a, b, op)); @@ -548,6 +569,10 @@ fn swept_prism(a: &Body, b: &Body, op: BoolOp) -> Option { } fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { + boolean_whole_with_refinement(a, b, op, false) +} + +fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -> Result> { if let Some(body) = swept_prism(a, b, op) { return Ok(Some(body)); } @@ -622,6 +647,25 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { }; (consistent, v) }; + if refine { + let keep: Vec> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); + // Bounded to two finer meshes; no tool displacement or relaxed acceptance tests. + for k in [1e-5, 1e-6] { + let tol = (size * k).max(1e-6); + if let Ok((solid, history)) = truck_boolean(a, b, op, None, tol) + && !solid.boundaries().is_empty() + && let Ok(body) = guard("refined boolean result", || recorded::truck_result(solid, history, size, &keep, &counts)) + && check(&body).0 + && crate::boolean_guard::check_retry(a, b, op, Some(&body)).is_ok() + { + let mut event = BooleanFallback::new(BooleanStrategy::RefinedTolerance); + event.tolerance_mm = tol; + record(event); + return Ok(Some(body)); + } + } + return Err(KernelError::Failed("no reliable result at refined tolerances".into())); + } // One body clear of or inside the other: no intersection needed. // Kept for the end when the facetings disagree with it: bodies that touch along curved // faces can pass through each other slightly, and often can't be intersected. diff --git a/crates/kernel/tests/boolean_props.proptest-regressions b/crates/kernel/tests/boolean_props.proptest-regressions index 83c0147e..3208747f 100644 --- a/crates/kernel/tests/boolean_props.proptest-regressions +++ b/crates/kernel/tests/boolean_props.proptest-regressions @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 739d5cbd9e471e0ff65bc2b7c1b9a323c2e769939c73def2e74fb17b0536c1d0 # shrinks to a = [0.0, 2.5, 30.0], sa = [22.5, 5.0, 5.0], b = [22.5, 7.5, 15.0], sb = [5.0, 5.0, 17.5] +cc 2adfe5ac6b6916fa22a415348bdd7ece29ad33e8114b94dea86ba8bc39156dee # shrinks to a = Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }, b = Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] } diff --git a/crates/kernel/tests/boolean_props.rs b/crates/kernel/tests/boolean_props.rs index 57a7d465..397308bf 100644 --- a/crates/kernel/tests/boolean_props.rs +++ b/crates/kernel/tests/boolean_props.rs @@ -67,6 +67,18 @@ fn prim(c: impl Strategy + Clone, s: impl Strategy + C fn grid_prim() -> impl Strategy { prim(grid(), grid_size()) } + +/// A union across only a box edge or corner is non-manifold, so the kernel may +/// refuse it. This is the same analytic boundary case allowed by box_box_exact. +fn edge_only_boxes(a: &Prim, b: &Prim) -> bool { + let (Prim::Box { at: a, size: sa }, Prim::Box { at: b, size: sb }) = (a, b) else { return false }; + let gap = [0, 1, 2].map(|i| a[i].max(b[i]) - (a[i] + sa[i]).min(b[i] + sb[i])); + gap.iter().all(|g| *g <= 0.0) && gap.iter().filter(|g| **g == 0.0).count() >= 2 +} + +fn edge_contact_refusal(a: &Prim, b: &Prim, op: BoolOp, result: &Result) -> bool { + op == BoolOp::Union && edge_only_boxes(a, b) && result.as_ref().is_err_and(|e| e.to_string().contains("zero-thickness edge contact")) +} fn free_prim() -> impl Strategy { prim(free(), free_size()) } @@ -95,8 +107,13 @@ fn close(x: f64, y: f64, scale: f64) -> bool { fn identity(a: &Prim, b: &Prim) -> Result<(), TestCaseError> { let (ba, bb) = (a.body(), b.body()); let (va, vb) = (vol(&ba), vol(&bb)); - let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union)?, run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?); - prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb); + let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union), run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?); + if edge_contact_refusal(a, b, BoolOp::Union, &vu) { + prop_assert!(close(vi, 0.0, va + vb), "edge-only contact must have no intersection volume"); + } else { + let vu = vu?; + prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb); + } prop_assert!(close(vc, va - vi, va + vb), "cut {vc:.3} != A-I {:.3}", va - vi); Ok(()) } @@ -137,7 +154,11 @@ proptest! { let (ba, bb) = (a.body(), b.body()); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect] { - let (ab, ba_) = (run(&ba, &bb, op)?, run(&bb, &ba, op)?); + let (ab, ba_) = (run(&ba, &bb, op), run(&bb, &ba, op)); + if edge_contact_refusal(&a, &b, op, &ab) && edge_contact_refusal(&a, &b, op, &ba_) { + continue; + } + let (ab, ba_) = (ab?, ba_?); prop_assert!(close(ab, ba_, s), "{op:?}: A,B gives {ab:.4} but B,A gives {ba_:.4}"); } } @@ -152,7 +173,11 @@ proptest! { let (ta, tb) = (mv(&ba), mv(&bb)); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] { - let (here, there) = (run(&ba, &bb, op)?, run(&ta, &tb, op)?); + let (here, there) = (run(&ba, &bb, op), run(&ta, &tb, op)); + if edge_contact_refusal(&a, &b, op, &here) && edge_contact_refusal(&a, &b, op, &there) { + continue; + } + let (here, there) = (here?, there?); prop_assert!(close(here, there, s), "{op:?} at origin {here:.4}, moved by {off} gives {there:.4}"); } } @@ -169,7 +194,11 @@ proptest! { let (ba, bb, ka, kb) = (a.body(), b.body(), sc(&a).body(), sc(&b).body()); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] { - let (one, big) = (run(&ba, &bb, op)?, run(&ka, &kb, op)? / (k * k * k)); + let (one, big) = (run(&ba, &bb, op), run(&ka, &kb, op)); + if edge_contact_refusal(&a, &b, op, &one) && edge_contact_refusal(&a, &b, op, &big) { + continue; + } + let (one, big) = (one?, big? / (k * k * k)); prop_assert!(close(one, big, s), "{op:?} at scale 1 gives {one:.4}, at scale {k} gives {big:.4} (normalised)"); } } @@ -233,6 +262,23 @@ proptest! { // Cases the property tests found, as plain tests. Ignored until the kernel bug is fixed: run them // with `cargo test -p solidcraft-kernel --test boolean_props -- --ignored`. +/// The saved commutativity case is an intentional non-manifold join refusal, +/// not a failure to join overlapping volumes. Intersection and cut remain valid. +#[test] +fn edge_only_box_join_is_refused_in_both_orders() { + let a = Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }; + let b = Prim::Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] }; + assert!(edge_only_boxes(&a, &b)); + assert!(!edge_only_boxes(&a, &Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 5.0] })); + let (a, b) = (a.body(), b.body()); + for (left, right) in [(&a, &b), (&b, &a)] { + let error = run(left, right, BoolOp::Union).expect_err("an edge-only join is non-manifold"); + assert!(error.to_string().contains("zero-thickness edge contact"), "{error}"); + assert_eq!(run(left, right, BoolOp::Intersect).unwrap(), 0.0); + assert!(close(run(left, right, BoolOp::Cut).unwrap(), vol(left), vol(left))); + } +} + fn check(r: Result<(), TestCaseError>) { if let Err(e) = r { panic!("{e}"); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1fc0cf8c..38c53a70 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,8 @@ semver = { workspace = true } sha2 = { workspace = true } minisign-verify = "0.3" zip = { workspace = true } +# Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn. +tiny-skia = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # The offscreen viewport render test opens a device without a window. @@ -32,3 +34,5 @@ pollster = { workspace = true } [build-dependencies] serde_json = { workspace = true } +# Reads assets/icons/ui/design/palette.toml and aliases.toml into compiled tables. +toml = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 63bc3b20..797da051 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -36,6 +36,10 @@ fn main() { } println!("cargo:rerun-if-changed=build.rs"); scenario_tests(); + if let Err(e) = icon_data() { + eprintln!("icon data: {e}"); + std::process::exit(1); + } } /// `$OUT_DIR/scenario_tests.rs`: a `#[test]` per scenario file, named after it; a scenario with @@ -67,3 +71,155 @@ fn scenario_tests() { let path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap_or_else(|_| ".".into())).join("scenario_tests.rs"); let _ = std::fs::write(path, out); } + +/// `assets/icons/ui/design`: the SVG masters, `palette.toml` (every colour role in light and dark, +/// and the accents) and `aliases.toml`, compiled into `$OUT_DIR/icon_data.rs` for `icon_svg.rs`. +/// A missing hex, a role an accent does not know or an alias to a missing master fails the build. +fn icon_data() -> Result<(), String> { + use std::fmt::Write as _; + let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let root = std::path::Path::new(&manifest).join("../../assets/icons/ui/design"); + let read = |name: &str| std::fs::read_to_string(root.join(name)).map_err(|e| format!("{name}: {e}")); + let table = |name: &str| read(name)?.parse::().map_err(|e| format!("{name}: {e}")); + let hex = |v: Option<&toml::Value>, what: &str| -> Result { + let s = v.and_then(toml::Value::as_str).ok_or_else(|| format!("{what}: missing hex"))?; + let ok = s.len() == 7 && s.starts_with('#') && s[1..].bytes().all(|b| b.is_ascii_hexdigit()); + if ok { Ok(s.to_ascii_lowercase()) } else { Err(format!("{what}: `{s}` is not #rrggbb")) } + }; + let rgb = |h: &str| format!("[0x{}, 0x{}, 0x{}]", &h[1..3], &h[3..5], &h[5..7]); + let palette = table("palette.toml")?; + let roles = palette.get("roles").and_then(toml::Value::as_table).ok_or("palette.toml: no [roles]")?; + let names: Vec<&String> = roles.keys().collect(); + let mut out = String::new(); + let _ = writeln!(out, "pub(crate) const ROLE_NAMES: &[&str] = &{:?};", names); + let (mut lights, mut darks, mut light_hex) = (Vec::new(), Vec::new(), Vec::new()); + for (name, role) in roles { + let l = hex(role.get("light"), &format!("role {name} light"))?; + let d = hex(role.get("dark"), &format!("role {name} dark"))?; + lights.push(rgb(&l)); + darks.push(rgb(&d)); + light_hex.push(hex(role.get("source").or_else(|| role.get("light")), &format!("role {name} source"))?); + } + let _ = writeln!(out, "pub(crate) const ROLE_SOURCE_HEX: &[&str] = &{:?};", light_hex); + let _ = writeln!(out, "pub(crate) const PALETTE: [&[[u8; 3]]; 2] = [&[{}], &[{}]];", lights.join(", "), darks.join(", ")); + let mut accent_names = Vec::new(); + let mut accents = Vec::new(); + if let Some(table) = palette.get("accents").and_then(toml::Value::as_table) { + for (acc, roles_of) in table { + let roles_of = roles_of.as_table().ok_or_else(|| format!("accent {acc}: not a table"))?; + let mut items = Vec::new(); + for (role, v) in roles_of { + let idx = names.iter().position(|n| *n == role).ok_or_else(|| format!("accent {acc}: unknown role {role}"))?; + let v = if let Some(link) = v.get("use").and_then(toml::Value::as_str) { + roles_of.get(link).ok_or_else(|| format!("accent {acc}: unknown linked role {link}"))? + } else { + v + }; + let l = hex(v.get("light"), &format!("accent {acc} {role} light"))?; + let d = hex(v.get("dark"), &format!("accent {acc} {role} dark"))?; + items.push(format!("({idx}, {}, {})", rgb(&l), rgb(&d))); + } + accent_names.push(acc.clone()); + accents.push(format!("&[{}]", items.join(", "))); + } + } + let _ = writeln!(out, "pub(crate) const ACCENT_NAMES: &[&str] = &{:?};", accent_names); + let _ = writeln!(out, "pub(crate) const ACCENTS: &[&[(usize, [u8; 3], [u8; 3])]] = &[{}];", accents.join(", ")); + // UI tokens and SVGs share the same primary strokes, including accessibility overrides. + for (constant, value) in [ + ("SKETCH_PRIMARY", palette.get("accents").and_then(|v| v.get("sketch"))), + ("CONSTRAINT_PRIMARY", palette.get("accents").and_then(|v| v.get("constraint"))), + ("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))), + ("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))), + ("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))), + ("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))), + ("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))), + ] { + let role = value.and_then(|v| v.get("active-3")); + let l = hex(role.and_then(|v| v.get("light")), constant)?; + let d = hex(role.and_then(|v| v.get("dark")), constant)?; + let _ = writeln!(out, "pub(crate) const {constant}: [[u8; 3]; 2] = [{}, {}];", rgb(&l), rgb(&d)); + } + let mut variants = Vec::new(); + if let Some(groups) = palette.get("variants").and_then(toml::Value::as_table) { + for (vision, group) in groups { + if !matches!(vision.as_str(), "red-green" | "blue-yellow") { + return Err(format!("unknown icon colour-vision group {vision}")); + } + for (accent, overrides) in group.as_table().ok_or("invalid variant group")? { + let a = accent_names.iter().position(|n| n == accent).ok_or_else(|| format!("variant: unknown accent {accent}"))?; + for (role, value) in overrides.as_table().ok_or("invalid variant roles")? { + let r = names.iter().position(|n| *n == role).ok_or_else(|| format!("variant: unknown role {role}"))?; + let l = hex(value.get("light"), "variant light")?; + let d = hex(value.get("dark"), "variant dark")?; + variants.push(format!("({vision:?}, {a}, {r}, {}, {})", rgb(&l), rgb(&d))); + } + } + } + } + let _ = writeln!(out, "pub(crate) const ACCENT_VARIANTS: &[(&str, usize, usize, [u8; 3], [u8; 3])] = &[{}];", variants.join(", ")); + // The masters, by family. + let mut svgs = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + let svg_dir = root.join("svg"); + println!("cargo:rerun-if-changed={}", root.display()); + for fam in ["model", "small", "flat", "workspace"] { + let dir = svg_dir.join(fam); + let mut files: Vec<_> = + std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.filter_map(Result::ok).map(|e| e.path()).collect(); + files.sort(); + for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == "svg")) { + let id = f.file_stem().and_then(|s| s.to_str()).ok_or("bad file name")?; + if fam != "small" { + ids.insert(id.to_owned()); + } + let abs = f.canonicalize().map_err(|e| e.to_string())?; + println!("cargo:rerun-if-changed={}", abs.display()); + svgs.push(format!("({id:?}, {fam:?}, include_str!({:?}))", abs.display().to_string())); + } + } + let _ = writeln!(out, "pub(crate) static SVGS: &[(&str, &str, &str)] = &[{}];", svgs.join(", ")); + let aliases = table("aliases.toml")?; + let mut al = Vec::new(); + if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { + for (alias, v) in t { + let (master, accent) = match v { + toml::Value::String(m) => (m.clone(), String::new()), + toml::Value::Table(t) => ( + t.get("master").and_then(toml::Value::as_str).ok_or_else(|| format!("alias {alias}: no master"))?.to_string(), + t.get("accent").and_then(toml::Value::as_str).unwrap_or("").to_string(), + ), + _ => return Err(format!("alias {alias}: expected a string or a table")), + }; + if !accent.is_empty() && !accent_names.contains(&accent) { + return Err(format!("alias {alias}: unknown accent {accent}")); + } + if !ids.contains(&master) { + return Err(format!("alias {alias}: unknown master {master}")); + } + al.push(format!("({alias:?}, {master:?}, {accent:?})")); + } + } + let _ = writeln!(out, "pub(crate) const ALIASES: &[(&str, &str, &str)] = &[{}];", al.join(", ")); + if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { + ids.extend(t.keys().cloned()); + } + let mut defaults = std::collections::BTreeMap::new(); + if let Some(groups) = palette.get("icon-accents").and_then(toml::Value::as_table) { + for (accent, icons) in groups { + if !accent_names.contains(accent) { + return Err(format!("icon-accents: unknown accent {accent}")); + } + for icon in icons.as_array().ok_or("icon-accents: expected icon array")? { + let icon = icon.as_str().ok_or("icon-accents: expected icon id")?; + if !ids.contains(icon) || defaults.insert(icon, accent).is_some() { + return Err(format!("icon-accents: unknown or repeated icon {icon}")); + } + } + } + } + let defaults: Vec<_> = defaults.iter().map(|(id, accent)| format!("({id:?}, {accent:?})")).collect(); + let _ = writeln!(out, "pub(crate) const ICON_ACCENTS: &[(&str, &str)] = &[{}];", defaults.join(", ")); + let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs"); + std::fs::write(dest, out).map_err(|e| e.to_string()) +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index ea104ed9..dd4c35b1 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1363,7 +1363,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { key: f.id.to_string(), row_key: format!("p:{}", f.id), label: f.name.clone(), - icon: "plane", + icon: crate::timeline::icon_of(&f.kind), visible: !app.session.visibility.hidden_origin.contains(&f.name), selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)), color: None, diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index e4fbc6df..f6fc0bb5 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -2,11 +2,11 @@ //! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that //! the tokens equal what was drawn before, and that a new colour cannot skip them. -use egui::{Color32, Pos2, Rect, vec2}; +use egui::Color32; use solidcraft_engine::render::Rgb; use solidcraft_engine::view::colors; -use crate::theme::{IconColors, Tokens}; +use crate::theme::Tokens; fn rgb(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) @@ -56,103 +56,10 @@ fn analysis_colours_reach_the_shader_in_its_order() { assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0); } -/// Every name a `match` arm in the icon source draws. -fn icon_names() -> Vec<&'static str> { - let src = include_str!("../../../assets/icons/ui/source/icons.rs"); - let body = src.split("#[cfg(test)]").next().unwrap_or(src); - let mut names = Vec::new(); - for line in body.lines() { - let line = line.trim_start(); - if !line.starts_with('"') { - continue; - } - let Some((pattern, _)) = line.split_once("=>") else { continue }; - let pattern = pattern.split(" if ").next().unwrap_or(pattern); - for part in pattern.split('|') { - let part = part.trim(); - if part.len() > 2 && part.starts_with('"') && part.ends_with('"') { - names.push(&part[1..part.len() - 1]); - } - } - } - names.sort_unstable(); - names.dedup(); - names -} - -/// Marker icon colours: every field its own colour, from `seed`. -fn marker_icons(seed: u8) -> IconColors { - let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i); - IconColors { - sheet: c(1), - sheet_2: c(2), - sheet_light: c(3), - sheet_line: c(4), - highlight: c(5), - glint: c(6), - folder: c(7), - plane: c(8), - fold: c(9), - stripe: c(10), - sky: c(11), - floor: c(12), - paint: [c(13), c(14), c(15)], - axes: [c(16), c(17), c(18)], - access: [c(19), c(20)], - draft: [c(21), c(22)], - curvature: [c(23), c(24), c(25)], - interference: c(26), - warning: c(27), - agent: c(28), - marker_halo: c(29), - } -} - -/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order). -fn drawn_colours(name: &str, seed: u8) -> Vec { - let ctx = egui::Context::default(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() }; - let icons = marker_icons(seed); - let out = ctx.run_ui(input, |ui| { - let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); - let ink = Color32::from_rgb(seed, 101, 1); - let fill = Color32::from_rgb(seed, 102, 2); - let accent = Color32::from_rgb(seed, 103, 3); - crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons); - }); - let text = format!("{:?}", out.shapes); - let b = text.as_bytes(); - let mut colours = Vec::new(); - for i in 0..b.len() { - let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit); - if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) { - colours.push(text[i..i + 12].to_string()); - } - } - colours -} - -/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the -/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made -/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the -/// same both times was written into the icon, and a mode could not reach it. -#[test] -fn every_icon_draws_only_colours_a_mode_can_reach() { - let names = icon_names(); - assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len()); - let mut fixed = Vec::new(); - for name in &names { - let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200)); - assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape"); - for (x, y) in a.iter().zip(&b) { - if x == y && !x.ends_with("_00") { - fixed.push(format!("{name}: {x}")); - } - } - } - fixed.dedup(); - assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}"); -} +// Icons no longer take their colours from the caller or from `IconColors`: the SVG masters name +// palette roles (`assets/icons/ui/design/palette.toml`, light and dark, plus accents) that +// `icon_svg` resolves. `icon_svg`'s own tests hold that every master parses and that only the +// palette's colours exist; the colour vision modes do not reach them yet. /// The most colour literals each UI file may hold, and why. None of them carries a meaning that a /// colour vision mode must change. A file with more fails here: make the new colour a token in @@ -171,6 +78,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[ ("documents.rs", 4, "white hover overlays on document tabs"), ("frame.rs", 1, "the window shadow"), ("gizmo.rs", 2, "the gizmo's neutral grey ball"), + ("icons.rs", 1, "the white tint that carries an icon image's opacity"), ("home.rs", 4, "white ink on accent buttons, and thumbnail tints"), ("ref_images.rs", 1, "a reference image's opacity tint"), ("sketch_palette.rs", 1, "white text on the accent button"), diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index c0ea5f5c..5bba2ea7 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -4,8 +4,9 @@ //! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published //! with their paper, Brettel on white and its own anchors. //! -//! When a token edit breaks a mode here, give that token a value in the mode's table -//! (`assets/styles/ui/palettes.rs`) that passes. +//! When a token edit breaks a mode here, adjust its measured variant. Shared icon accents live +//! in `assets/icons/ui/design/palette.toml`; `assets/styles/ui/palettes.rs` links those generated +//! values to UI tokens and owns the remaining UI-only colour-vision adjustments. use std::collections::BTreeSet; @@ -627,14 +628,14 @@ fn each_mode_changes_only_its_table() { "preview_sketch", ]; let mut red_green_light = red_green_both.to_vec(); - red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]); + red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]); for (dark, mode, want) in [ (false, ColorVision::Protan, set(&red_green_light)), (true, ColorVision::Protan, set(&red_green_both)), (false, ColorVision::Deutan, set(&red_green_light)), (true, ColorVision::Deutan, set(&red_green_both)), - (false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])), - (true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])), + (false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "preview_sketch", "analysis.hatch"])), + (true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "glyph_text", "preview_sketch", "analysis.hatch"])), ] { let base = Tokens::themed(dark, ColorVision::Off); assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}"); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index cc1d18e4..5bd9d269 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -342,8 +342,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Workspace => { let rendering = crate::render_workspace::active(app); let mut v = vec![ - act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }), - act("ui.workspace.render", "Render", "camera") + act("ui.workspace.design", "Design", "ws_design").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }), + act("ui.workspace.render", "Render", "ws_render") .with(json!({"workspace":"render"})) .key(if rendering { "current" } else { "" }) .on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()), diff --git a/crates/ui-egui/src/deferred.rs b/crates/ui-egui/src/deferred.rs index 382e957e..12140f86 100644 --- a/crates/ui-egui/src/deferred.rs +++ b/crates/ui-egui/src/deferred.rs @@ -108,11 +108,12 @@ impl SolveApp { let _ = tx.send(()); }) .ok()?; - match rx.recv_timeout(wait) { - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision }) - } - _ => None, + // Zero explicitly requests off-thread application, even if a fast worker + // finishes before this thread is scheduled again. Apply on the next frame. + if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { + Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision }) + } else { + None } } diff --git a/crates/ui-egui/src/icon_svg.rs b/crates/ui-egui/src/icon_svg.rs new file mode 100644 index 00000000..b4708be6 --- /dev/null +++ b/crates/ui-egui/src/icon_svg.rs @@ -0,0 +1,894 @@ +//! The UI icon renderer: the SVG masters in `assets/icons/ui/design/svg`, drawn at the pixel size +//! they are shown. +//! +//! The masters are a small, linted SVG subset (`tools/lint_icons.py`): absolute `M L C Q A Z` +//! paths, circles, groups with a clip or opacity, masks and clip paths. They are embedded at build +//! time together with `palette.toml` (every colour role in light and dark, and the accents) and +//! `aliases.toml` (see `build.rs`), parsed once, and rasterised with tiny-skia into a texture per +//! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a +//! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*` +//! roles. GAP instead erases preceding artwork to transparency; it never paints a background. +//! Changing a colour is an edit to `palette.toml` only. +//! +//! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the +//! placeholder, and `every_master_parses` holds that none do. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use tiny_skia as sk; + +include!(concat!(env!("OUT_DIR"), "/icon_data.rs")); + +/// The id drawn for every icon that has no master yet. +pub const PLACEHOLDER: &str = "placeholder"; + +/// How a shape is painted. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Paint { + None, + /// Erase earlier artwork; never paint a theme background into the texture. + Knockout, + /// `currentColor`: the `line` role. + Line, + /// A palette role (index into `ROLE_NAMES`). + Role(usize), + /// A fixed colour (mask artwork). + Fixed([u8; 3]), +} + +#[derive(Debug)] +struct Node { + path: sk::Path, + fill: Paint, + rule: sk::FillRule, + stroke: Paint, + width: f32, + dash: Vec, + opacity: f32, + mask: Option, +} + +#[derive(Debug)] +enum Item { + Node(Node), + Group { clip: Option, opacity: f32, items: Vec }, +} + +/// A parsed master. +#[derive(Debug)] +pub struct Doc { + items: Vec, + masks: Vec>, + clips: Vec>, +} + +// --------------------------------------------------------------------------------------------- +// Parsing + +struct Tag<'a> { + name: &'a str, + attrs: Vec<(&'a str, &'a str)>, + close: bool, + selfclose: bool, +} + +impl Tag<'_> { + fn get(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|(n, _)| *n == k).map(|(_, v)| *v) + } + fn num(&self, k: &str) -> Result { + self.get(k).ok_or_else(|| format!("missing {k}"))?.trim().parse::().map_err(|e| format!("{k}: {e}")) + } +} + +fn tags(src: &str) -> Result>, String> { + let mut out = Vec::new(); + let mut rest = src; + while let Some(i) = rest.find('<') { + let after = rest.get(i + 1..).ok_or("truncated")?; + let j = after.find('>').ok_or("unterminated tag")?; + let body = after.get(..j).ok_or("truncated")?; + rest = after.get(j + 1..).ok_or("truncated")?; + if body.starts_with('?') || body.starts_with('!') { + continue; + } + let (close, body) = match body.strip_prefix('/') { + Some(b) => (true, b), + None => (false, body), + }; + let (selfclose, body) = match body.strip_suffix('/') { + Some(b) => (true, b), + None => (false, body), + }; + let body = body.trim(); + let name_end = body.find(char::is_whitespace).unwrap_or(body.len()); + let name = body.get(..name_end).ok_or("bad tag")?; + let mut attrs = Vec::new(); + let mut a = body.get(name_end..).unwrap_or(""); + while let Some(eq) = a.find('=') { + let key = a.get(..eq).ok_or("bad attribute")?.trim(); + let v = a.get(eq + 1..).ok_or("bad attribute")?.trim_start(); + let q = v.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute")?; + let v = v.get(1..).ok_or("bad attribute")?; + let end = v.find(q).ok_or("unterminated attribute")?; + attrs.push((key, v.get(..end).ok_or("bad attribute")?)); + a = v.get(end + 1..).unwrap_or(""); + } + out.push(Tag { name, attrs, close, selfclose }); + } + Ok(out) +} + +/// An SVG elliptical arc as cubic Béziers appended to `pb`. +#[allow(clippy::too_many_arguments)] +fn arc(pb: &mut sk::PathBuilder, x1: f32, y1: f32, rx: f32, ry: f32, rot_deg: f32, large: bool, sweep: bool, x2: f32, y2: f32) { + let (mut rx, mut ry) = (rx.abs(), ry.abs()); + if rx < 1e-6 || ry < 1e-6 || ((x1 - x2).abs() < 1e-6 && (y1 - y2).abs() < 1e-6) { + pb.line_to(x2, y2); + return; + } + let phi = rot_deg.to_radians(); + let (sp, cp) = phi.sin_cos(); + let (dx, dy) = ((x1 - x2) / 2.0, (y1 - y2) / 2.0); + let (x1p, y1p) = (cp * dx + sp * dy, -sp * dx + cp * dy); + let lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if lam > 1.0 { + let s = lam.sqrt(); + rx *= s; + ry *= s; + } + let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p; + let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p; + let sign = if large == sweep { -1.0 } else { 1.0 }; + let coef = sign * (num / den).max(0.0).sqrt(); + let (cxp, cyp) = (coef * rx * y1p / ry, -coef * ry * x1p / rx); + let (cx, cy) = (cp * cxp - sp * cyp + (x1 + x2) / 2.0, sp * cxp + cp * cyp + (y1 + y2) / 2.0); + let ang = |ux: f32, uy: f32, vx: f32, vy: f32| (ux * vy - uy * vx).atan2(ux * vx + uy * vy); + let (ux, uy) = ((x1p - cxp) / rx, (y1p - cyp) / ry); + let (vx, vy) = ((-x1p - cxp) / rx, (-y1p - cyp) / ry); + let theta = ang(1.0, 0.0, ux, uy); + let mut dtheta = ang(ux, uy, vx, vy); + if !sweep && dtheta > 0.0 { + dtheta -= std::f32::consts::TAU; + } else if sweep && dtheta < 0.0 { + dtheta += std::f32::consts::TAU; + } + let n = ((dtheta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8); + let delta = dtheta / n as f32; + let t = 4.0 / 3.0 * (delta / 4.0).tan(); + let map = |x: f32, y: f32| (cx + rx * cp * x - ry * sp * y, cy + rx * sp * x + ry * cp * y); + for i in 0..n { + let a1 = theta + delta * i as f32; + let a2 = a1 + delta; + let (s1, c1) = a1.sin_cos(); + let (s2, c2) = a2.sin_cos(); + let (p1x, p1y) = map(c1 - t * s1, s1 + t * c1); + let (p2x, p2y) = map(c2 + t * s2, s2 - t * c2); + let (ex, ey) = map(c2, s2); + pb.cubic_to(p1x, p1y, p2x, p2y, ex, ey); + } +} + +fn parse_path(d: &str) -> Result { + let mut toks: Vec<&str> = Vec::new(); + let b = d.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = *b.get(i).ok_or("path")?; + if c.is_ascii_alphabetic() { + toks.push(d.get(i..=i).ok_or("path")?); + i += 1; + } else if c == b'-' || c == b'.' || c.is_ascii_digit() { + let s = i; + i += 1; + while let Some(&n) = b.get(i) { + if n.is_ascii_digit() + || n == b'.' + || ((n == b'-' || n == b'+') && matches!(b.get(i - 1), Some(b'e' | b'E'))) + || n == b'e' + || n == b'E' + { + i += 1; + } else { + break; + } + } + toks.push(d.get(s..i).ok_or("path")?); + } else { + i += 1; + } + } + let mut pb = sk::PathBuilder::new(); + let (mut k, mut cmd) = (0usize, ' '); + let (mut x, mut y) = (0.0f32, 0.0f32); + let mut open = false; + let num = |k: &mut usize| -> Result { + let t = toks.get(*k).ok_or("path: missing number")?; + *k += 1; + t.parse::().map_err(|e| format!("path: {e}")) + }; + while k < toks.len() { + let t = *toks.get(k).ok_or("path")?; + if t.len() == 1 && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) { + cmd = t.chars().next().ok_or("path")?; + k += 1; + } + match cmd { + 'M' => { + (x, y) = (num(&mut k)?, num(&mut k)?); + pb.move_to(x, y); + open = true; + cmd = 'L'; + } + 'L' => { + (x, y) = (num(&mut k)?, num(&mut k)?); + pb.line_to(x, y); + } + 'C' => { + let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?]; + pb.cubic_to(v[0], v[1], v[2], v[3], v[4], v[5]); + (x, y) = (v[4], v[5]); + } + 'Q' => { + let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?]; + pb.quad_to(v[0], v[1], v[2], v[3]); + (x, y) = (v[2], v[3]); + } + 'A' => { + let (rx, ry, rot) = (num(&mut k)?, num(&mut k)?, num(&mut k)?); + let (fa, fs) = (num(&mut k)? != 0.0, num(&mut k)? != 0.0); + let (ex, ey) = (num(&mut k)?, num(&mut k)?); + arc(&mut pb, x, y, rx, ry, rot, fa, fs, ex, ey); + (x, y) = (ex, ey); + } + 'Z' => { + if open { + pb.close(); + } + } + other => return Err(format!("path command {other} is not supported")), + } + } + pb.finish().ok_or_else(|| "empty path".to_string()) +} + +fn parse_paint(v: Option<&str>, in_mask: bool) -> Result { + let Some(v) = v else { return Ok(Paint::None) }; + match v { + "none" => Ok(Paint::None), + "currentColor" => Ok(Paint::Line), + "white" if in_mask => Ok(Paint::Fixed([255, 255, 255])), + "black" if in_mask => Ok(Paint::Fixed([0, 0, 0])), + _ => { + let lower = v.to_ascii_lowercase(); + ROLE_SOURCE_HEX + .iter() + .position(|h| *h == lower) + .map(|role| if ROLE_NAMES.get(role) == Some(&"gap") { Paint::Knockout } else { Paint::Role(role) }) + .ok_or_else(|| format!("colour {v} is not a palette colour")) + } + } +} + +fn parse_dash(v: Option<&str>) -> Vec { + let mut d: Vec = v.map(|s| s.split([' ', ',']).filter_map(|n| n.parse().ok()).collect()).unwrap_or_default(); + if d.len() % 2 == 1 { + d.extend(d.clone()); + } + d +} + +fn url_ref(v: &str) -> Option<&str> { + v.strip_prefix("url(#")?.strip_suffix(')') +} + +enum Frame { + Group { clip: Option, opacity: f32, items: Vec }, + Mask(usize), + Clip(usize), + Defs, +} + +impl Doc { + pub fn parse(src: &str) -> Result { + let mut doc = Doc { items: Vec::new(), masks: Vec::new(), clips: Vec::new() }; + let (mut mask_ids, mut clip_ids): (HashMap, HashMap) = (HashMap::new(), HashMap::new()); + let mut stack: Vec = Vec::new(); + for t in tags(src)? { + match (t.name, t.close) { + ("svg" | "title", _) => {} + ("defs", false) => stack.push(Frame::Defs), + ("mask", false) => { + doc.masks.push(Vec::new()); + mask_ids.insert(t.get("id").ok_or("mask without id")?.to_string(), doc.masks.len() - 1); + stack.push(Frame::Mask(doc.masks.len() - 1)); + } + ("clipPath", false) => { + doc.clips.push(Vec::new()); + clip_ids.insert(t.get("id").ok_or("clipPath without id")?.to_string(), doc.clips.len() - 1); + stack.push(Frame::Clip(doc.clips.len() - 1)); + } + ("g", false) => { + let clip = match t.get("clip-path").and_then(url_ref) { + Some(id) => Some(*clip_ids.get(id).ok_or("unknown clip")?), + None => None, + }; + let opacity = t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0); + stack.push(Frame::Group { clip, opacity, items: Vec::new() }); + } + ("defs" | "mask" | "clipPath" | "g", true) => match stack.pop() { + Some(Frame::Group { clip, opacity, items }) => { + let item = Item::Group { clip, opacity, items }; + match stack.last_mut() { + Some(Frame::Group { items, .. }) => items.push(item), + _ => doc.items.push(item), + } + } + Some(_) => {} + None => return Err("unbalanced tags".into()), + }, + ("path" | "circle", _) => { + let in_mask = matches!(stack.last(), Some(Frame::Mask(_))); + let path = if t.name == "path" { + parse_path(t.get("d").ok_or("path without d")?)? + } else { + sk::PathBuilder::from_circle(t.num("cx")?, t.num("cy")?, t.num("r")?).ok_or("bad circle")? + }; + let mask = match t.get("mask").and_then(url_ref) { + Some(id) => Some(*mask_ids.get(id).ok_or("unknown mask")?), + None => None, + }; + let dash = parse_dash(t.get("stroke-dasharray")); + let mut stroke = parse_paint(t.get("stroke"), in_mask)?; + let visible_stroke = match stroke { + Paint::Line => true, + Paint::Role(role) => !matches!(ROLE_NAMES.get(role).copied(), Some("gap" | "active-1" | "active-2" | "active-3")), + _ => false, + }; + // Non-blue dotted/dashed references share the guide colour. + // Blue source paints retain their semantic accent; keep + // background knockouts and literal mask paints unchanged. + if !dash.is_empty() && visible_stroke && !in_mask { + stroke = Paint::Role(ROLE_NAMES.iter().position(|name| *name == "guide").ok_or("missing guide colour")?); + } + let node = Node { + path, + fill: if t.get("fill").is_none() && in_mask { Paint::None } else { parse_paint(t.get("fill").or(Some("none")), in_mask)? }, + rule: if t.get("fill-rule") == Some("evenodd") { sk::FillRule::EvenOdd } else { sk::FillRule::Winding }, + stroke, + width: t.get("stroke-width").and_then(|w| w.parse().ok()).unwrap_or(1.0), + dash, + opacity: t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0), + mask, + }; + match stack.last_mut() { + Some(Frame::Mask(i)) => doc.masks.get_mut(*i).ok_or("mask")?.push(node), + Some(Frame::Clip(i)) => doc.clips.get_mut(*i).ok_or("clip")?.push(node), + Some(Frame::Group { items, .. }) => items.push(Item::Node(node)), + _ => doc.items.push(Item::Node(node)), + } + } + (other, _) => return Err(format!("element <{other}> is not supported")), + } + let _ = t.selfclose; + } + if !stack.is_empty() { + return Err("unbalanced tags".into()); + } + Ok(doc) + } +} + +// --------------------------------------------------------------------------------------------- +// Rendering + +/// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for +/// the base palette). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Look { + pub dark: bool, + pub accent: Option, + pub vision: crate::theme::ColorVision, +} + +impl Look { + fn rgb(self, role: usize) -> [u8; 3] { + let theme = usize::from(self.dark); + if ROLE_NAMES.get(role) == Some(&"guide") { + return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]); + } + let vision = match self.vision { + crate::theme::ColorVision::Off => "", + crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green", + crate::theme::ColorVision::Tritan => "blue-yellow", + }; + if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) { + return if self.dark { *d } else { *l }; + } + if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) { + return if self.dark { *d } else { *l }; + } + PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]) + } + + fn resolve(self, p: Paint) -> Option<[u8; 3]> { + match p { + Paint::None | Paint::Knockout => None, + Paint::Fixed(c) => Some(c), + Paint::Role(r) => Some(self.rgb(r)), + Paint::Line => ROLE_NAMES.iter().position(|n| *n == "line").map(|r| self.rgb(r)), + } + } +} + +fn paint_of(c: [u8; 3], alpha: f32) -> sk::Paint<'static> { + let mut p = sk::Paint::default(); + p.set_color_rgba8(c[0], c[1], c[2], (alpha.clamp(0.0, 1.0) * 255.0).round() as u8); + p.anti_alias = true; + p +} + +fn shape_paint(paint: Paint, look: Look, alpha: f32) -> Option> { + if paint == Paint::Knockout { + let mut paint = paint_of([0, 0, 0], alpha); + paint.blend_mode = sk::BlendMode::DestinationOut; + Some(paint) + } else { + look.resolve(paint).map(|colour| paint_of(colour, alpha)) + } +} + +fn draw_node(pm: &mut sk::Pixmap, n: &Node, look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) { + let own; + let mask = if let Some(m) = n.mask { + own = doc.masks.get(m).and_then(|nodes| { + let mut sub = sk::Pixmap::new(pm.width(), pm.height())?; + for node in nodes { + draw_node(&mut sub, node, look, tf, doc, None, 1.0); + } + let mut own = sk::Mask::from_pixmap(sub.as_ref(), sk::MaskType::Luminance); + if let Some(parent) = clip { + for (value, parent) in own.data_mut().iter_mut().zip(parent.data()) { + *value = ((u16::from(*value) * u16::from(*parent) + 127) / 255) as u8; + } + } + Some(own) + }); + own.as_ref() + } else { + clip + }; + let alpha = n.opacity * opacity; + if let Some(paint) = shape_paint(n.fill, look, alpha) { + pm.fill_path(&n.path, &paint, n.rule, tf, mask); + } + if let Some(paint) = shape_paint(n.stroke, look, alpha) { + let stroke = sk::Stroke { + width: n.width, + line_cap: sk::LineCap::Round, + line_join: sk::LineJoin::Round, + dash: if n.dash.is_empty() { None } else { sk::StrokeDash::new(n.dash.clone(), 0.0) }, + ..sk::Stroke::default() + }; + pm.stroke_path(&n.path, &paint, &stroke, tf, mask); + } +} + +fn draw_items(pm: &mut sk::Pixmap, items: &[Item], look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) { + for item in items { + match item { + Item::Node(n) => draw_node(pm, n, look, tf, doc, clip, opacity), + Item::Group { clip: c, opacity: o, items } => { + let mask = c.and_then(|c| doc.clips.get(c)).and_then(|nodes| { + let mut m = sk::Mask::new(pm.width(), pm.height())?; + for n in nodes { + m.fill_path(&n.path, n.rule, true, tf); + } + Some(m) + }); + draw_items(pm, items, look, tf, doc, mask.as_ref().or(clip), opacity * o); + } + } + } +} + +/// Rasterise `doc` into a square `px` × `px` premultiplied RGBA image. +pub fn render(doc: &Doc, look: Look, px: u32) -> Option { + let mut pm = sk::Pixmap::new(px, px)?; + let s = px as f32 / 64.0; + draw_items(&mut pm, &doc.items, look, sk::Transform::from_scale(s, s), doc, None, 1.0); + Some(pm) +} + +// --------------------------------------------------------------------------------------------- +// The icon table + +struct Entry { + doc: Doc, +} + +fn table() -> &'static (HashMap<&'static str, Entry>, HashMap<&'static str, Entry>) { + static T: OnceLock<(HashMap<&'static str, Entry>, HashMap<&'static str, Entry>)> = OnceLock::new(); + T.get_or_init(|| { + let (mut big, mut small) = (HashMap::new(), HashMap::new()); + for (id, fam, src) in SVGS { + match Doc::parse(src) { + Ok(doc) => { + let map = if *fam == "small" { &mut small } else { &mut big }; + // Model masters precede flat glyphs: keep the reviewed command artwork. + map.entry(*id).or_insert(Entry { doc }); + } + Err(e) => log::warn!("icon {fam}/{id}: {e}"), + } + } + (big, small) + }) +} + +/// A resolved icon: the master to draw and the accent it is drawn in. +#[derive(Clone, Copy, Debug)] +pub struct Resolved { + pub master: &'static str, + pub accent: Option, + pub placeholder: bool, +} + +fn accent_index(name: &str) -> Option { + if name.is_empty() { None } else { ACCENT_NAMES.iter().position(|a| *a == name) } +} + +/// A semantic default belongs to the requested id, never to the caller's current workspace. +fn default_accent(name: &str) -> Option { + ICON_ACCENTS.iter().find(|(id, _)| *id == name).and_then(|(_, accent)| accent_index(accent)) +} + +/// Primary semantic stroke, also used by toolbar affordances. The palette owns its value. +pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 { + let look = Look { dark, accent: resolve(name).accent, vision }; + role_colour("active-3", look) +} + +pub(crate) fn role_colour(name: &str, look: Look) -> egui::Color32 { + if name == "gap" { + return egui::Color32::TRANSPARENT; + } + let role = ROLE_NAMES.iter().position(|n| *n == name); + let [r, g, b] = role.map(|role| look.rgb(role)).unwrap_or([255, 0, 255]); + crate::theme::icon_color([r, g, b]) +} + +/// The master and accent for an app icon id: an alias (with its accent), a master of that name, +/// or the placeholder. +pub fn resolve(name: &str) -> Resolved { + let (big, _) = table(); + let placeholder = Resolved { master: PLACEHOLDER, accent: None, placeholder: true }; + if let Some((_, master, accent)) = ALIASES.iter().find(|(a, _, _)| *a == name) { + return match big.get_key_value(master) { + Some((k, _)) => Resolved { master: k, accent: accent_index(accent).or_else(|| default_accent(name)), placeholder: false }, + None => placeholder, + }; + } + match big.get_key_value(name) { + Some((k, _)) if name != PLACEHOLDER => Resolved { master: k, accent: default_accent(name), placeholder: false }, + _ => placeholder, + } +} + +/// The master to draw at `px` pixels: the small-slot variant where one exists. +pub fn doc_for(master: &str, px: u32) -> Option<&'static Doc> { + let (big, small) = table(); + if let Some(e) = small.get(master).filter(|_| px <= 24) { + return Some(&e.doc); + } + big.get(master).or_else(|| big.get(PLACEHOLDER)).map(|e| &e.doc) +} + +/// Every master id (not the small variants), for tests and tooling. +pub fn master_ids() -> Vec<&'static str> { + let mut v: Vec<_> = table().0.keys().copied().collect(); + v.sort_unstable(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_master_parses() { + for (id, fam, src) in SVGS { + if let Err(e) = Doc::parse(src) { + panic!("{fam}/{id}: {e}"); + } + } + assert!(table().0.contains_key(PLACEHOLDER), "the placeholder master exists"); + } + + #[test] + fn aliases_point_at_masters_and_known_accents() { + for (alias, master, accent) in ALIASES { + assert!(table().0.contains_key(master), "{alias} -> {master}"); + assert!(accent.is_empty() || ACCENT_NAMES.contains(accent), "{alias}: accent {accent}"); + assert!(!resolve(alias).placeholder); + } + } + + #[test] + fn non_blue_dashed_strokes_use_guides_but_preserve_accents_and_knockouts() { + let svg = ""; + let doc = Doc::parse(svg).unwrap(); + let guide = ROLE_NAMES.iter().position(|name| *name == "guide").unwrap(); + let active = ROLE_NAMES.iter().position(|name| *name == "active-3").unwrap(); + let Item::Node(line) = &doc.items[0] else { panic!("expected line") }; + let Item::Node(knockout) = &doc.items[1] else { panic!("expected knockout") }; + let Item::Node(blue) = &doc.items[2] else { panic!("expected blue line") }; + assert_eq!(line.stroke, Paint::Role(guide)); + assert_eq!(knockout.stroke, Paint::Knockout); + assert_eq!(blue.stroke, Paint::Role(active)); + for dark in [false, true] { + for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]); + let pm = render(&doc, look, 64).unwrap(); + assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide)); + assert_eq!([pm.pixel(9, 52).unwrap().red(), pm.pixel(9, 52).unwrap().green(), pm.pixel(9, 52).unwrap().blue()], look.rgb(active)); + } + } + } + + #[test] + fn knockout_fills_and_strokes_erase_earlier_layers_and_allow_later_foreground() { + let doc = Doc::parse("").unwrap(); + for dark in [false, true] { + for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + let pm = render(&doc, look, 64).unwrap(); + assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background"); + assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background"); + assert_eq!(pm.pixel(20, 20).unwrap().alpha(), 255, "foreground is drawn after erasure"); + assert_eq!(pm.pixel(2, 2).unwrap().alpha(), 255, "unaffected base stays opaque"); + assert!((126..=128).contains(&pm.pixel(44, 20).unwrap().alpha()), "partial opacity erases partially"); + } + } + } + + #[test] + fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() { + let doc = Doc::parse("").unwrap(); + let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0); + assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip"); + } + + #[test] + fn accepted_badges_and_environment_sun_have_transparent_holes() { + for dark in [false, true] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT); + for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] { + let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap(); + assert_eq!(pm.pixel(x, y).unwrap().alpha(), 0, "{id}: GAP must not be a painted theme colour"); + } + } + } + + #[test] + fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() { + for dark in [false, true] { + for px in [16, 24, 64] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] { + let doc = doc_for(id, px).unwrap(); + let pm = render(doc, look, px).unwrap(); + assert_eq!(pm.pixel(px - 2, px - 2).unwrap().alpha(), 0, "{id} escapes its native clip"); + assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents"); + } + } + let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer"); + } + } + + #[test] + fn command_meanings_choose_the_same_accent_on_every_surface() { + let accent = |name| resolve(name).accent.map(|i| ACCENT_NAMES[i]); + for c in solidcraft_engine::command_specs() { + let want = if c.id.starts_with("surface.") { + Some("surface") + } else if c.panel == "CONSTRAINTS" { + Some("constraint") + } else if matches!(c.id, "sketch.construction" | "sketch.centerline") { + Some("construction") + } else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") { + Some("sketch") + } else if c.id == "file.save_mesh" { + Some("mesh") + } else { + continue; + }; + assert_eq!(accent(c.icon), want, "{} ({})", c.id, c.icon); + } + for id in ["move", "scale", "copy", "delete", "export", "ground", "axis_two_planes"] { + assert_eq!(accent(id), None, "shared utility / modelling icon {id}"); + } + for (alias, master) in [("sketch_move", "move"), ("sketch_scale", "scale"), ("surface_fair", "spline_cv"), ("mesh_export", "export")] { + assert_eq!(resolve(alias).master, resolve(master).master, "reuse accepted shapes"); + } + } + + #[test] + fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() { + for dark in [false, true] { + for vision in crate::theme::ColorVision::ALL { + let tokens = crate::theme::Tokens::themed(dark, vision); + assert_eq!(primary("line", dark, vision), tokens.sketch_accent); + assert_eq!(primary("c_parallel", dark, vision), tokens.glyph_text); + assert_eq!(primary("centerline", dark, vision), tokens.construction_geom); + assert_eq!(primary("construction", dark, vision), tokens.construction_geom); + } + } + } + + #[test] + fn semantic_ramps_preserve_silhouettes_and_passive_faces_in_all_themes() { + use crate::theme::ColorVision; + for id in ["line", "c_parallel", "construction", "centerline", "sketch_rect_pattern", "surface_extrude", "mesh_export"] { + let resolved = resolve(id); + for dark in [false, true] { + for vision in ColorVision::ALL { + let look = Look { dark, accent: resolved.accent, vision }; + let base = Look { accent: None, ..look }; + for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] { + let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap(); + assert_eq!(look.rgb(role), base.rgb(role), "{id} changes passive paint"); + } + for px in [16, 24, 64] { + let doc = doc_for(resolved.master, px).unwrap(); + let changed = render(doc, look, px).unwrap(); + let original = render(doc, base, px).unwrap(); + assert!(changed.pixels().iter().zip(original.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{id}: silhouette at {px}px"); + } + } + } + } + } + + #[test] + fn command_artwork_resolves_and_construct_methods_are_distinct() { + let specs = solidcraft_engine::command_specs(); + for command in &specs { + assert!(!resolve(command.icon).placeholder, "{}: {}", command.id, command.icon); + } + let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect(); + let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect(); + assert_eq!(construction.len(), 19); + assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities"); + } + + #[test] + fn helper_controls_resolve_without_command_registry_entries() { + for name in ["agent_chat", "normal", "infinite", "pattern_flip", "pattern_center"] { + assert!(!resolve(name).placeholder, "UI helper: {name}"); + } + } + + #[test] + fn sketch_construction_modes_have_distinct_icons() { + let families: &[&[&str]] = &[ + &["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"], + &["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"], + &["sketch.slot.arc_three_point", "sketch.slot.arc_center"], + &["sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"], + ]; + for commands in families { + let icons: std::collections::HashSet<_> = commands.iter().map(|id| solidcraft_engine::find_command(id).unwrap().icon).collect(); + assert_eq!(icons.len(), commands.len(), "Sketch modes share an icon: {commands:?}"); + for icon in icons { + assert!(!resolve(icon).placeholder, "{icon}"); + } + } + } + + #[test] + fn contact_actions_have_distinct_icons_from_interference_analysis() { + let mut icons = std::collections::HashSet::new(); + let interference = solidcraft_engine::find_command("inspect.interference").unwrap().icon; + for id in ["contact.enable_sets", "contact.enable_all", "contact.disable_all", "contact.create"] { + let icon = solidcraft_engine::find_command(id).unwrap().icon; + assert_ne!(icon, interference, "{id} is a contact control, not an interference result"); + assert!(icons.insert(icon), "contact controls share an icon: {id}"); + assert!(!resolve(icon).placeholder, "{id}: {icon}"); + } + } + + #[test] + fn reviewed_model_folder_is_also_the_folder_drawn_in_the_app() { + let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2; + let expected = Doc::parse(source).unwrap(); + for dark in [false, true] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap(); + let reviewed = render(&expected, look, 64).unwrap(); + assert_eq!(actual.data(), reviewed.data()); + } + } + + #[test] + fn unknown_names_draw_the_placeholder() { + assert!(resolve("no_such_icon").placeholder); + assert!(resolve("").placeholder); + assert!(!resolve("extrude").placeholder); + } + + fn coverage(pm: &sk::Pixmap) -> usize { + pm.pixels().iter().filter(|p| p.alpha() > 0).count() + } + + #[test] + fn every_master_draws_something_at_every_size_in_both_themes() { + for id in master_ids() { + for dark in [false, true] { + for px in [16, 20, 26, 64] { + let Some(doc) = doc_for(id, px) else { panic!("{id}") }; + let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px) + .unwrap_or_else(|| panic!("{id} at {px}")); + assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank"); + } + } + } + } + + fn rgba(pm: &sk::Pixmap) -> Vec { + pm.data().to_vec() + } + + #[test] + fn dark_theme_and_accents_change_colour_not_shape() { + let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude")); + let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + assert_ne!(rgba(&light), rgba(&dark)); + let r = resolve("surface_extrude"); + assert_eq!(r.master, "extrude"); + let surface = + render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + assert!(r.accent.is_some()); + assert_ne!(rgba(&light), rgba(&surface)); + // Same silhouette: the alpha channel is identical. + let alpha = |pm: &sk::Pixmap| pm.pixels().iter().map(|p| p.alpha()).collect::>(); + assert_eq!(alpha(&light), alpha(&surface)); + } + + #[test] + fn hostile_artwork_is_an_error_not_a_panic() { + for bad in [ + "", + "<", + "", + "", + "", + "", + "", + "", + ] { + let _ = Doc::parse(bad); + } + assert!(Doc::parse("").is_err()); + assert!(Doc::parse("").is_err()); + } + + #[test] + fn palette_roles_are_distinct_and_complete() { + assert_eq!(ROLE_NAMES.len(), ROLE_SOURCE_HEX.len()); + let mut seen = std::collections::HashSet::new(); + assert!( + ROLE_NAMES.iter().zip(ROLE_SOURCE_HEX).filter(|(n, _)| **n != "line").all(|(_, h)| seen.insert(*h)), + "two roles share a source identifier" + ); + assert!(ROLE_NAMES.contains(&"line")); + assert_eq!(PALETTE.len(), 2); + assert!(PALETTE.iter().all(|p| p.len() == ROLE_NAMES.len())); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2a5bab03..089fa4da 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,9 +1,16 @@ -//! Stable UI rendering boundary for the collected artwork. Compiled only at L5. -#[path = "../../../assets/icons/ui/source/icons.rs"] -mod source; -pub use source::paint; -#[cfg(test)] -pub(crate) use source::paint_with; +//! Stable UI rendering boundary for the icon artwork. Compiled only at L5. +//! +//! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`, +//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the +//! accents), never from the caller. An icon with no master yet draws the placeholder. +//! The glyph, marker, navigation and pointer sources below are not command icons. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use egui::{Color32, Painter, Rect, TextureHandle, TextureOptions, pos2}; + +use crate::icon_svg::{self, Look}; + #[path = "../../../assets/icons/ui/source/glyphs.rs"] pub(crate) mod glyphs; #[path = "../../../assets/icons/ui/source/markers.rs"] @@ -12,3 +19,96 @@ pub(crate) mod markers; pub(crate) mod navigation; #[path = "../../../assets/icons/ui/source/pointer.rs"] pub(crate) mod pointer; + +/// Textures already rasterised: one per (master, look, pixel size). +#[derive(Clone, Default)] +struct Cache(Arc>>); + +/// Draw the icon `name` in `r`. The ink, fill and accent colours the callers pass are no longer +/// used: the palette decides. Only the ink's opacity is kept, so a dimmed (disabled) button dims +/// its icon. +pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, _fill: Color32, _accent: Color32) { + paint_icon(p, r, name, f32::from(ink.a()) / 255.0); +} + +/// [`paint`] with an explicit opacity (0 to 1). +pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) { + paint_on(p, r, name, None, opacity); +} + +/// Draw the icon `name` for a surface that is always dark (the application bar), whatever the +/// theme: `dark: Some(true)`. `None` follows the theme. +pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32) { + let ctx = p.ctx(); + let ppp = ctx.pixels_per_point().max(0.5); + let side = r.width().min(r.height()); + let px = (side * ppp).round().clamp(8.0, 512.0) as u32; + let resolved = icon_svg::resolve(name); + let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() }; + let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone()); + let Ok(mut map) = cache.0.lock() else { return }; + let key = (resolved.master, look, px); + if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) { + let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return }; + let Some(pm) = icon_svg::render(doc, look, px) else { return }; + let image = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data()); + slot.insert(ctx.load_texture(format!("icon:{}:{px}", resolved.master), image, TextureOptions::LINEAR)); + } + let Some(tex) = map.get(&key) else { return }; + // Snap the image to whole physical pixels so it stays crisp. + let size = px as f32 / ppp; + let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp); + let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8); + p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::{self, ColorVision}; + + #[test] + fn actual_uploaded_icon_pixels_follow_semantics_and_vision_cache_changes() { + let ctx = egui::Context::default(); + let previous = theme::vision(); + for dark in [false, true] { + for vision in [ColorVision::Off, ColorVision::Protan, ColorVision::Tritan] { + theme::set_vision(vision); + for icon in ["line", "c_parallel", "construction", "centerline", "surface_extrude", "mesh_export", "contact_new"] { + let expected = if icon == "construction" { + icon_svg::role_colour("guide", Look { dark, accent: None, vision }) + } else { + icon_svg::primary(icon, dark, vision) + }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0)); + paint_on(ui.painter(), r, icon, Some(dark), 1.0); + }); + assert!( + output.textures_delta.set.iter().any(|(_, deltas)| deltas.iter().any(|delta| match &delta.image { + egui::ImageData::Color(image) => image.pixels.contains(&expected), + })), + "{icon}, {dark}, {vision:?}: actual texture misses its semantic stroke or reused a stale cache entry" + ); + if icon == "contact_new" { + let image = output + .textures_delta + .set + .values() + .flatten() + .find_map(|delta| match &delta.image { + egui::ImageData::Color(image) if image.size == [64, 64] => Some(image), + _ => None, + }) + .unwrap(); + assert_eq!(image.pixels.get(48 * 64 + 48), Some(&Color32::TRANSPARENT), "uploaded plus must stay transparent"); + } + // This headless test inspects uploads instead of sending them + // to a GPU; acknowledge the deltas before they are dropped. + output.textures_delta.clear(); + } + } + } + theme::set_vision(previous); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40e05f7b..dc6ecbc1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,7 @@ pub mod i18n; mod i18n_menu_tests; #[cfg(test)] mod i18n_ui_tests; +pub mod icon_svg; pub mod icons; pub mod inference; pub mod install; diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index c88a575c..6686d596 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -283,7 +283,8 @@ fn moved(x: &str, y: &str, angle: &str) -> Mod { fn the_command_now_has_a_toolbar_placement_and_opens_a_window() { let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered"); assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY")); - assert_eq!(spec.icon, "move"); + assert_eq!(spec.icon, "sketch_move"); + assert_eq!(crate::icon_svg::resolve(spec.icon).master, "move", "reuse the finished Move drawing"); let mut h = rectangle(); let before = curve_ids(&h).len(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ef64fc23..629b12e0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,7 +3,6 @@ //! uses comes from [`Tokens`]. use std::cell::Cell; -use std::sync::atomic::{AtomicBool, Ordering}; #[path = "../../../assets/styles/ui/palettes.rs"] mod palettes; @@ -11,10 +10,11 @@ mod palettes; use egui::{Color32, Stroke}; use serde::{Deserialize, Serialize}; -static DARK: AtomicBool = AtomicBool::new(true); - thread_local! { - /// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app + /// Theme state belongs to the UI thread drawing the frame. Concurrent app/test renderers + /// must not change one another's palette while a frame or icon texture is being built. + static DARK: Cell = const { Cell::new(true) }; + /// The colour vision mode, per thread. Each app /// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the /// thread that draws it. Tests run many apps side by side on their own threads in one process: /// with one global, a test in a mode changed the colours under another one @@ -26,7 +26,12 @@ thread_local! { /// Is the dark theme on? pub fn is_dark() -> bool { - DARK.load(Ordering::Relaxed) + DARK.get() +} + +/// The common conversion boundary for compiled icon palette data. +pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } /// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for @@ -333,7 +338,7 @@ pub fn material_color(name: &str) -> Option { /// Switch themes (the visuals follow on the next [`apply`]). pub fn set_dark(dark: bool) { - DARK.store(dark, Ordering::Relaxed); + DARK.set(dark); } fn visuals(dark: bool) -> egui::Visuals { @@ -373,6 +378,24 @@ pub fn apply(ctx: &egui::Context) { #[cfg(test)] mod tests { + #[test] + fn concurrent_ui_renderers_keep_their_own_theme_and_vision() { + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + for (dark, vision) in [(false, super::ColorVision::Off), (true, super::ColorVision::Protan)] { + let barrier = &barrier; + scope.spawn(move || { + super::set_dark(dark); + super::set_vision(vision); + barrier.wait(); + assert_eq!(super::is_dark(), dark); + assert_eq!(super::vision(), vision); + assert_eq!(super::Tokens::get().sketch_accent, super::Tokens::themed(dark, vision).sketch_accent); + }); + } + }); + } + /// Title and section bands stand out from a dialog, the title more than its sections. #[test] fn heading_bands_are_darker_than_the_dialog_in_order() { diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 867f4d83..3904b026 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -3,7 +3,10 @@ pub(crate) use crate::icons::glyphs::transport_glyph; use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2}; use serde_json::json; -use solidcraft_engine::doc::FeatureKind; +use solidcraft_engine::doc::{ + FeatureKind, PlaneRef, + construct::{AxisDef, PointDef}, +}; use crate::theme::Tokens; use crate::{SolveApp, icons}; @@ -13,14 +16,18 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Sketch { .. } => "sketch", FeatureKind::Extrude { .. } => "extrude", FeatureKind::SurfaceExtrude { .. } => "surface_extrude", - FeatureKind::Unstitch { .. } => "unstitch", - FeatureKind::ReverseNormal { .. } => "reverse_normal", + FeatureKind::Unstitch { .. } => "surface_unstitch", + FeatureKind::ReverseNormal { .. } => "surface_reverse_normal", FeatureKind::SurfaceRevolve { .. } => "surface_revolve", FeatureKind::SurfaceSweep { .. } => "surface_sweep", FeatureKind::SurfaceLoft { .. } => "surface_loft", FeatureKind::SurfaceOffset { .. } => "surface_offset", - FeatureKind::SurfaceRuled { .. } => "ruled", - FeatureKind::SurfaceUntrim { .. } => "untrim", + FeatureKind::SurfaceRuled { .. } => "surface_ruled", + FeatureKind::SurfaceUntrim { .. } => "surface_untrim", + FeatureKind::SurfaceBlend { .. } => "surface_blend", + FeatureKind::SurfaceBoundary { .. } => "surface_boundary", + FeatureKind::SurfaceMatch { .. } => "continuity", + FeatureKind::SurfaceFair { .. } => "surface_fair", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", @@ -30,11 +37,25 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Torus { .. } => "torus", FeatureKind::Combine { .. } => "combine", FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect", + FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Path { .. }, .. } => "pattern_path", FeatureKind::Pattern { .. } => "pattern_circ", FeatureKind::Mirror { .. } => "mirror", - FeatureKind::ConstructionPlane { .. } => "plane", - FeatureKind::ConstructionAxis { .. } => "axis", - FeatureKind::ConstructionPoint { .. } => "point", + FeatureKind::ConstructionPlane { plane } => construction_plane_icon(plane), + FeatureKind::ConstructionAxis { def } => match def { + AxisDef::ThroughFace { .. } => "axis_through_cylinder", + AxisDef::NormalToFace { .. } => "axis_normal_to_face", + AxisDef::TwoPlanes { .. } => "axis_two_planes", + AxisDef::TwoPoints { .. } => "axis_two_points", + AxisDef::Edge { .. } => "axis_edge", + }, + FeatureKind::ConstructionPoint { def } => match def { + PointDef::At { .. } => "point_vertex", + PointDef::TwoEdges { .. } => "point_two_edges", + PointDef::ThreePlanes { .. } => "point_three_planes", + PointDef::Center { .. } => "point_center", + PointDef::EdgeAndPlane { .. } => "point_edge_plane", + PointDef::AlongPath { .. } => "point_along_path", + }, FeatureKind::Hole { .. } => "hole", FeatureKind::Loft { .. } => "loft", FeatureKind::Sweep { .. } => "sweep", @@ -43,10 +64,43 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import", + FeatureKind::BodyTransfer { .. } => "move", + FeatureKind::Thread { .. } => "thread", + FeatureKind::SplitFace { .. } => "split_face", + FeatureKind::Scale { .. } => "scale", + FeatureKind::OffsetFace { .. } => "offset_face", + FeatureKind::DeleteFace { .. } => "delete_face", + FeatureKind::Remove { .. } => "delete", + FeatureKind::BoundingSolid { .. } => "bounding_solid", + FeatureKind::Pipe { .. } => "pipe", + FeatureKind::Emboss { .. } => "emboss", + FeatureKind::ReplaceFace { .. } => "replace_face", + FeatureKind::Align { .. } => "align", + FeatureKind::Coil { .. } => "coil", + FeatureKind::SheetPerforate { .. } => "perforate", + FeatureKind::BoundaryFill { .. } => "boundary_fill", + FeatureKind::Patch { .. } => "surface_patch", + FeatureKind::Stitch { .. } => "surface_stitch", + FeatureKind::Thicken { .. } => "surface_thicken", + FeatureKind::SurfaceExtend { .. } => "surface_extend", + FeatureKind::SurfaceTrim { .. } => "surface_trim", other => crate::workspace::feature_icon(other).unwrap_or("feature"), } } +fn construction_plane_icon(plane: &PlaneRef) -> &'static str { + match plane { + PlaneRef::AtAngle { .. } => "plane_angle", + PlaneRef::Midplane { .. } => "plane_midplane", + PlaneRef::TwoEdges { .. } => "plane_two_edges", + PlaneRef::ThreePoints { .. } => "plane_three_points", + PlaneRef::Tangent { .. } => "plane_tangent", + PlaneRef::AlongPath { .. } => "plane_along_path", + PlaneRef::Perpendicular { .. } => "plane_perpendicular", + _ => "plane", + } +} + #[derive(Clone, Debug, PartialEq)] enum Drag { Marker, @@ -439,6 +493,93 @@ mod tests { use crate::scenario::Harness; + #[test] + fn construction_history_matches_the_tool_that_created_it() { + use solidcraft_engine::doc::{ + FeatureKind, PlaneRef, + construct::{AxisDef, GeoRef, PointDef}, + }; + use solidcraft_engine::geom::Vec3; + let point = || GeoRef::Point { p: Vec3::ZERO }; + let reference = || Box::new(point()); + let mut cases = Vec::new(); + for (id, def) in [ + ("through_cylinder", AxisDef::ThroughFace { face: point() }), + ("normal_to_face", AxisDef::NormalToFace { face: point(), point: point() }), + ("two_planes", AxisDef::TwoPlanes { a: point(), b: point() }), + ("two_points", AxisDef::TwoPoints { a: point(), b: point() }), + ("edge", AxisDef::Edge { edge: point() }), + ] { + cases.push((format!("construct.axis.{id}"), FeatureKind::ConstructionAxis { def })); + } + for (id, def) in [ + ("vertex", PointDef::At { point: point() }), + ("two_edges", PointDef::TwoEdges { a: point(), b: point() }), + ("three_planes", PointDef::ThreePlanes { a: point(), b: point(), c: point() }), + ("center", PointDef::Center { of: point() }), + ("edge_plane", PointDef::EdgeAndPlane { edge: point(), plane: point() }), + ("along_path", PointDef::AlongPath { path: point(), t: "0.5".into() }), + ] { + cases.push((format!("construct.point.{id}"), FeatureKind::ConstructionPoint { def })); + } + for (id, plane) in [ + ("midplane", PlaneRef::Midplane { a: reference(), b: reference() }), + ("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }), + ("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }), + ("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }), + ("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }), + ("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }), + ] { + cases.push((format!("construct.plane.{id}"), FeatureKind::ConstructionPlane { plane })); + } + for (id, feature) in cases { + let command = solidcraft_engine::find_command(&id).unwrap(); + let icon = super::icon_of(&feature); + assert_eq!(icon, command.icon, "{id}: history and toolbar disagree"); + assert!(!crate::icon_svg::resolve(icon).placeholder, "{id}"); + } + } + + #[test] + fn new_surface_and_web_history_icons_match_their_commands() { + let cases = [ + ("surface.ruled", json!({"type":"surface_ruled", "body":"A", "edges":[], "distance":"5"})), + ("surface.untrim", json!({"type":"surface_untrim", "face":{"body":"A", "point":[0,0,0]}})), + ("surface.unstitch", json!({"type":"unstitch", "body":"A"})), + ("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})), + ("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})), + ("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})), + ("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})), + ("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})), + ("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})), + ("solid.web", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2", "web":true})), + ]; + for (id, definition) in cases { + let feature: solidcraft_engine::doc::FeatureKind = serde_json::from_value(definition).unwrap(); + let command = solidcraft_engine::find_command(id).unwrap(); + assert_eq!(super::icon_of(&feature), command.icon, "{id}: history and toolbar disagree"); + } + } + + #[test] + fn a_path_pattern_keeps_its_path_icon_in_history() { + use solidcraft_engine::doc::{FeatureKind, PatternKind}; + let feature = FeatureKind::Pattern { + features: Vec::new(), + bodies: Vec::new(), + pattern: PatternKind::Path { + path_sketch: solidcraft_engine::doc::SketchId(0), + path: Vec::new(), + count: "2".into(), + spacing: "10".into(), + extent: false, + flip: false, + orient: false, + }, + }; + assert_eq!(super::icon_of(&feature), solidcraft_engine::find_command("solid.pattern.path").unwrap().icon); + } + fn two_boxes(left: bool) -> Harness { let mut h = Harness::new(); h.app.preferences.timeline_left = left; diff --git a/crates/ui-egui/src/timeline_marks_tests.rs b/crates/ui-egui/src/timeline_marks_tests.rs index 906b586b..7406d782 100644 --- a/crates/ui-egui/src/timeline_marks_tests.rs +++ b/crates/ui-egui/src/timeline_marks_tests.rs @@ -1,5 +1,6 @@ //! A failed feature is marked in the bottom timeline and the Feature History by a shape, not only -//! its colour (#972): the error mark (a disc with a cross) where a warning's mark goes. +//! its colour (#972): the error mark where a warning's mark goes (now an icon image, so these +//! tests look for the 10-point image at the mark's place). use egui::{Pos2, Rect, Shape, vec2}; use serde_json::json; @@ -27,20 +28,23 @@ fn features(app: &SolveApp) -> Vec<(String, bool)> { app.session.doc.features.iter().map(|f| (f.name.clone(), app.session.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect() } -/// The discs filled in the error colour (either theme's) that one frame of `draw` paints. +/// The centres of the 10 to 12-point icon images one frame of `draw` paints: the failed-feature mark +/// (the sample has no warnings, so every such image is an error mark). fn error_discs(app: &mut SolveApp, draw: fn(&mut SolveApp, &mut egui::Ui)) -> Vec { crate::scenario::clear_handles(); let ctx = egui::Context::default(); let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)); - let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui)); - let errors = [crate::theme::Tokens::light().error, crate::theme::Tokens::dark().error]; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui)); + // Icons upload textures; dropping their deltas unapplied would panic. + out.textures_delta.clear(); let mut found = Vec::new(); for clipped in &out.shapes { each(&clipped.shape, &mut |s| { - if let Shape::Circle(c) = s - && errors.contains(&c.fill) - { - found.push(c.center); + if let Shape::Mesh(m) = s { + let b = m.calc_bounds(); + if matches!(m.texture_id, egui::TextureId::Managed(_)) && (b.width() - 11.0).abs() < 1.5 && (b.height() - 11.0).abs() < 1.5 { + found.push(b.center()); + } } }); } @@ -81,27 +85,3 @@ fn a_failed_feature_is_marked_in_the_feature_history() { let got = marked(&mut app, crate::timeline_left::left_timeline, |item, d| (d.y - item.y).abs() < 1.5 && d.x > item.x); assert_eq!(got, want, "an error mark on each failed feature and on no other"); } - -/// The mark is its own drawing: a disc with a cross in the caller's colours, not the fallback -/// square, and a different shape from the warning's triangle. -#[test] -fn the_error_mark_is_a_disc_with_a_cross() { - let ctx = egui::Context::default(); - let (ink, fill) = (egui::Color32::from_rgb(1, 2, 3), egui::Color32::from_rgb(4, 5, 6)); - let draw = |name: &'static str| { - let out = ctx.run_ui(egui::RawInput::default(), |ui| { - crate::icons::paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)), name, ink, fill, fill); - }); - let mut shapes = Vec::new(); - for c in &out.shapes { - each(&c.shape, &mut |s| shapes.push(s.clone())); - } - shapes - }; - let error = draw("error"); - let discs = error.iter().filter(|s| matches!(s, Shape::Circle(c) if c.fill == fill && c.stroke.color == ink)).count(); - let strokes = error.iter().filter(|s| matches!(s, Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink))).count(); - assert_eq!((discs, strokes), (1, 2), "one disc and the two strokes of a cross: {error:?}"); - assert_ne!(format!("{error:?}"), format!("{:?}", draw("warning"))); - assert_ne!(format!("{error:?}"), format!("{:?}", draw("no such icon")), "drawn, not the fallback square"); -} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 56cf0166..c6d5a47d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -10,39 +10,10 @@ use crate::{SolveApp, icons}; pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"]; -/// The accent stroke colour for a panel's icons: constraint commands use the same red as the -/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep -/// `sketch_accent`, and every other tab keeps the plain `accent`. -fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 { - if panel == "CONSTRAINTS" { - t.glyph_text - } else if tab == "SKETCH" { - t.sketch_accent - } else { - t.accent - } -} - -/// The accent stroke colour a command's icon keeps on every surface that draws it, where the -/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the -/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the -/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while -/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint` -/// draws both of these icon names in the accent its caller hands it, never in a colour of its own. -/// The caller is the only place the rule can live. Every other command has no fixed colour. It -/// keeps the accent of whichever surface draws it. -fn fixed_accent(t: Tokens, id: &str) -> Option { - match id { - "sketch.construction" | "sketch.centerline" => Some(t.construction_geom), - _ => None, - } -} - -/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own -/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that. -/// Every other command takes its `panel_accent`. -fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 { - fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel)) +/// Legacy paint arguments and non-SVG affordances follow the same semantic palette as the icon. +/// The requested icon id determines its meaning on ribbons, menus and drag previews alike. +fn command_accent(icon: &str) -> Color32 { + crate::icon_svg::primary(icon, crate::theme::is_dark(), crate::theme::vision()) } /// Panels shown on each tab (in order). Commands come from the registry by (tab, panel). @@ -281,8 +252,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if enabled && resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); } - let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) }; - icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2])); + icons::paint_on(ui.painter(), br.shrink(5.0), icon, Some(true), if enabled { 1.0 } else { 0.35 }); let clicked = resp.on_hover_text(tip).clicked(); enabled && clicked }; @@ -351,7 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }); ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0)); - icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar); + icons::paint_on(ui.painter(), icon, "bug_report", Some(true), 1.0); if report_label { ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text); } @@ -364,7 +334,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if gresp.hovered() { ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30)); } - icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar); + icons::paint_on(ui.painter(), gear.shrink(4.0), "settings", Some(true), 1.0); crate::scenario::publish_handle("titlebar:preferences", gear.center()); let prefs_tip = if cfg!(target_os = "macos") { format!("{} (Cmd+,)", app.tr("ui.preferences")) @@ -597,7 +567,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0)); let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click()); painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent); + let workspace_icon = if crate::render_workspace::active(app) { "ws_render" } else { "ws_design" }; + icons::paint( + &painter, + Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), + workspace_icon, + t.icon, + t.icon_fill, + t.accent, + ); painter.text( pos2(ws.center().x - 5.0, ws.top() + 52.0), Align2::CENTER_CENTER, @@ -719,7 +697,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { painter.rect_filled(br, 4.0, t.hover); } if let Some(c) = shown.first().or(cmds.first()) { - let acc = command_accent(t, &tab, panel, c.id); + let acc = command_accent(c.icon); icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc); } caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim); @@ -769,14 +747,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } else if resp.hovered() && info.enabled { painter.rect_filled(br, 4.0, t.hover); } - let (ink, fill, acc) = if info.enabled { - (t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id)) - } else { - (t.border, t.panel_header, t.border) - }; + let (ink, fill, acc) = + if info.enabled { (t.icon, t.icon_fill, command_accent(selected.icon)) } else { (t.border, t.panel_header, t.border) }; let icon = match selected.id { "sketch.finish" => "finish", - "config.table" => "configuration_table", _ => selected.icon, }; let icon_rect = @@ -1105,11 +1079,9 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine p.rect_filled(rect, 3.0, t.hover); crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center()); } - // Every other row keeps the plain accent it always has. A constraint command's row switches - // to the constraint red, to match its button on the ribbon. A command with a `fixed_accent` - // keeps that colour ahead of either. + // The icon keeps its semantic accent in a drop-down as well as on the ribbon. let (ink, fill, acc, text) = if info.enabled { - let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent }); + let acc = command_accent(c.icon); (t.icon, t.icon_fill, acc, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) @@ -1243,7 +1215,7 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) { let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag"))); let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0)); p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside); - icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent)); + icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, command_accent(c.icon)); if removing { let s = Stroke::new(2.0, t.error); let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0)); @@ -1788,69 +1760,6 @@ mod panel_drop_tests { } } -#[cfg(test)] -mod icon_accent_tests { - use super::*; - - /// `icons.rs` pins one half of the construction icon contract. `construction` and - /// `centerline` take their colour from whatever accent their caller hands over. That half - /// stays green when the caller hands over the wrong colour. That is exactly what the toolbar - /// did. This pins the other half: the colour the toolbar actually passes for those two - /// commands, on every surface the toolbar draws them on. - #[test] - fn the_toolbar_passes_construction_orange_for_both_line_type_commands() { - for t in [Tokens::light(), Tokens::dark()] { - // The regression itself: the SKETCH tab's accent is green. These two icons drew in - // it. Construction orange has to be a colour no panel accent can supply. - assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent"); - assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent"); - assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red"); - for id in ["sketch.construction", "sketch.centerline"] { - assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent"); - // Its ribbon button, a collapsed panel's single button, and a drop-down row. - assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon"); - assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon"); - assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row"); - } - } - } - - /// Those two and no others. Every other command's icon keeps the accent of the panel it sits - /// on, so the constraint red and the sketch green both survive the exception above. - #[test] - fn no_other_command_overrides_the_accent_of_its_own_panel() { - let t = Tokens::light(); - let specs = solidcraft_engine::command_specs(); - let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect(); - fixed.sort_unstable(); - assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent"); - for c in &specs { - if fixed.contains(&c.id) { - continue; - } - let want = panel_accent(t, c.tab, c.panel); - assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id); - } - // The fix did not flatten every icon to one colour. A sketch tool is still green, a - // constraint still red, a solid tool still blue. A missing id fails this as well. - let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id)); - assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent)); - assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text)); - assert_eq!(accent_of("solid.extrude"), Some(t.accent)); - } - - /// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is - /// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws - /// from the caller's accent. - #[test] - fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() { - for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] { - let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon)); - assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel"); - } - } -} - #[cfg(test)] mod dropdown_toggle_tests { use crate::scenario::{Harness, rect_of}; diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index ed99b7eb..bec39cab 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -152,8 +152,9 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> { FeatureKind::SheetConvert { .. } => "convert_sheet", FeatureKind::Boss { .. } => "boss", FeatureKind::Lip { .. } => "lip", - FeatureKind::SnapFit { .. } => "snap", + FeatureKind::SnapFit { .. } => "snap_fit", FeatureKind::Rest { .. } => "rest", + FeatureKind::Rib { web: true, .. } => "web", FeatureKind::Rib { .. } => "rib", _ => return None, }) diff --git a/docs/robustness.md b/docs/robustness.md index 3516f468..6c350610 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -76,11 +76,14 @@ fails, or leaves a sliver face (narrower than 1/200 of the part), it is tried th other way too: one way can push a face into a curved face it only touched (a cut whose end lies tangent to a round side) and leave a sliver of it, where the other way parts them (#897, #827). -Exact planar and piecewise fallbacks report zero displacement. The existing -volume and spatial acceptance checks still apply to all results. - -The command that adds a feature carries the same `warning` in its reply, so a -script or an agent sees such a retry without asking the timeline. +Exact planar and piecewise fallbacks report zero displacement. When a conical +cutter meets a body with earlier conical faces (pointed bores copied in a pattern), +two finer, unshifted passes are tried first, at 1e-5 and 1e-6 of their size (never +below 1e-6 mm). A coarse first cut can leave tiny junctions that later copies cannot +intersect, so refining only the failing copy is too late. Every candidate must pass +the volume, membership and spatial checks; a rejected candidate continues to the +next tolerance, then the ordinary routes. The existing acceptance checks still +apply to all results. A join or a cut that needed any retry is then held to what it can do at all (`crates/kernel/src/boolean_guard.rs`, #616): the result is closed, a join diff --git a/docs/ui-icon-design-proposal.md b/docs/ui-icon-design-proposal.md new file mode 100644 index 00000000..bcb8d7b7 --- /dev/null +++ b/docs/ui-icon-design-proposal.md @@ -0,0 +1,8 @@ +# UI icon design — superseded proposal + +The format and style decisions are implemented. SVG masters, theme role mapping, +isometric geometry and the owner's reviewed set are authoritative. + +Use [the binding style guide](../assets/icons/ui/STYLE_GUIDE.md) and +[the maintained review workflow](../assets/icons/ui/README.md). The earlier +undecided proposal must not be used to create a competing style or drawing pipeline. diff --git a/docs/ui-icon-inventory.md b/docs/ui-icon-inventory.md new file mode 100644 index 00000000..8377a804 --- /dev/null +++ b/docs/ui-icon-inventory.md @@ -0,0 +1,22 @@ +# UI icon maintenance + +The authoritative authoring specification is +[assets/icons/ui/STYLE_GUIDE.md](../assets/icons/ui/STYLE_GUIDE.md), with mandatory +agent instructions beside it. Start at +[the UI icon README](../assets/icons/ui/README.md) for the included review app, +canonical resource paths, new-icon scaffold, exports and checks. + +Launch the repository-owned review tool with: + +```sh +python3 -B assets/icons/ui/design/tools/icon_status.py --serve +``` + +Open http://127.0.0.1:8770/. Every discovered icon without a current acceptance is +Open. SVGs, aliases and source references are scanned automatically; unused masters +are included. Counts and CSV reports are derived at runtime, not maintained here. +`--csv` writes `target/icon-inventory.csv`. + +The design and cleanup plan is [ui-icon-maintenance-plan.md](ui-icon-maintenance-plan.md). +The old proposal is superseded. SVG masters, palette, aliases, the original Rust +viewport glyphs and owner review records remain the application resources. diff --git a/docs/ui-icon-maintenance-plan.md b/docs/ui-icon-maintenance-plan.md new file mode 100644 index 00000000..121d6a2a --- /dev/null +++ b/docs/ui-icon-maintenance-plan.md @@ -0,0 +1,82 @@ +# Durable icon maintenance — implementation plan + +The goal is for a new contributor or agent, without any chat history, to produce +icons that match the finished set and to find every icon that still needs review. +The SVG masters and valid acceptance records must survive this work unchanged. + +## 1. Establish ownership and clean the resource tree + +- Keep production assets, font licences, platform exports, editable native source + documents and the original SVG masters. Never remove an asset used by packaging. +- Retire superseded numbered authoring batches: they contain duplicate functions, + earlier revisions and implicit overrides. The SVGs are the editable authority. + Keep a reusable geometry library, explicit bases and a new-icon scaffold instead. +- Remove the generated inventory CSV from the source collection; generate reports + under ignored `target/`. Remove the empty proposal-era stand-in list. +- Remove the unused historical macOS raster/SVG exports while preserving their + editable VectorCraft document. Keep a local recovery copy under `target/`. +- Update attribution alongside every resource addition, retirement and move. + +## 2. Give future authors an unmissable specification + +- Link the rules from root AGENTS.md, assets/AGENTS.md and icons/ui/AGENTS.md. +- Specify the categories, exact palette and weights, isometric projection and + face shading, canonical cube/slab bases, action arrows, points, holes, badges, + overlap order, detail limits and optical padding. +- Name finished SVG references for each category and explain what to copy from + their visual language. Commercial artwork remains forbidden. +- Provide a safe starter command that refuses to overwrite an existing icon, + and a validation command that checks attribution, structure and coverage. +- Record a short first-pass acceptance rubric and the complete review workflow. + +## 3. Preserve a small, repository-owned review application + +- Keep the existing Python entry point and loopback URL, requiring only Python 3.11+. +- Split the page, CSS and JavaScript into readable versioned source files. +- Discover SVG masters, aliases, command references, nested UI callers and known + wrapper/table arguments on every launch and explicit rescan. Show unused masters. +- Every discovered, non-final icon is Open, including icons with notes. Missing + artwork is visible and cannot be accepted. Report unassigned commands separately. +- Final means the current artwork matches its saved hash. Changed artwork returns + to Open and can be accepted again deliberately; never silently refresh its hash. +- A normal opening starts at Open / Everything with an empty search. Retain theme + and size preferences; preserve temporary filters only for a live artwork reload. +- Keep autosave, light/dark, small-size variants, notes and portable JSON export. + Export queues neutral artwork feedback locally; it does not invoke a model. +- Preserve notes edited since an export, stale-page merge behavior and accepted art. + +## 4. Verify the basic implementation + +- Test discovery of newly added and unused masters, aliases, missing artwork, + changed acceptance hashes, nested UI callers, stale saves and exact export notes. +- Check the web UI in both themes and at small sizes. Check the style-reference + page and scaffold against the finished geometry. +- Run focused checks in the background; report actual results. Full repository CI + is still required before a local commit. Existing engine robustness failures do + not authorize changing unrelated geometry or accepting failed quality gates. +- Keep this iteration local; do not push or change the existing draft PR #853. + +## Scope of this first implementation + +No icon redesign, palette change, packaging change, new approval state database, +cloud service, model-specific automation or replacement of Rust viewport graphics. +Style quality still needs visual review; lint cannot decide whether a new symbol +communicates its operation well. + +## Basic version implemented + +- The media collection retains the production SVGs and platform assets. The + superseded bulk generators, generated inventory, empty stand-in list and unused + historical exports have been retired; the historical editable native master remains. +- Mandatory instructions are linked from root, assets and UI-specific AGENTS.md. + The style guide, curated references and reusable bases are included in the repo. +- The review interface is split into versioned HTML/CSS/JavaScript, with discovery + on opening/rescan, valid-hash acceptance, visible shared masters, atomic state + saves, exact local exports and protection against stale acceptance and note loss. +- `cargo xtask icons` and the CI repository-check job enforce the icon checks and + regression tests. All existing SVG bytes and initial acceptance hashes were preserved. + +Remaining limits: source discovery recognises the documented literal call patterns, +not every possible Rust expression. Add a fixture when introducing a new icon +wrapper convention. Visual quality, semantic clarity and acceptance remain human +review decisions; the tool does not automatically redraw or finalise artwork. diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index da2c49f0..ff06222b 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -14,14 +14,14 @@ "10": {"area":2809.248846,"bodies":1,"edges":39,"error":null,"faces":16,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut box","fillet","hole"],"unsupported":0,"volume":6696.329905}, "11": {"area":7858.300481,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","rectangular pattern","fillet"],"unsupported":0,"volume":34605.048259}, "12": {"area":4668.664174,"bodies":2,"edges":59,"error":null,"faces":25,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","chamfer","join cylinder","join box","cut cylinder","circular pattern"],"unsupported":3,"volume":11874.471611}, - "13": {"area":2101.521015,"bodies":1,"edges":103,"error":null,"faces":41,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939548}, + "13": {"area":2101.521015,"bodies":1,"edges":101,"error":null,"faces":40,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939548}, "14": {"area":10553.267409,"bodies":1,"edges":43,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","fillet"],"unsupported":0,"volume":10369.008764}, "15": {"area":2312.730168,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere","join cylinder"],"unsupported":0,"volume":5728.661237}, "16": {"area":8872.873884,"bodies":3,"edges":49,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":3,"steps":["box","join cylinder","circular pattern","hole"],"unsupported":0,"volume":44953.403148}, "17": {"area":6022.921702,"bodies":1,"edges":48,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","shell","cut cylinder"],"unsupported":0,"volume":2981.269959}, "18": {"area":5524.583817,"bodies":4,"edges":98,"error":"boolean Cut: the operation failed: no result","faces":44,"outcome":"failed","rejected":0,"shells":4,"steps":["extruded profile","join cylinder","circular pattern","cut cylinder","hole","cut cylinder","hole","circular pattern"],"unsupported":0,"volume":21568.016672}, "19": {"area":3440.869757,"bodies":1,"edges":42,"error":null,"faces":16,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":5909.174021}, - "20": {"area":5727.101189,"bodies":1,"edges":116,"error":null,"faces":45,"outcome":"ok","rejected":1,"shells":1,"steps":["extruded profile","fillet","cut box","cut cylinder","hole","circular pattern","cut box"],"unsupported":0,"volume":17142.727051}, + "20": {"area":5726.92956,"bodies":1,"edges":116,"error":null,"faces":45,"outcome":"ok","rejected":1,"shells":1,"steps":["extruded profile","fillet","cut box","cut cylinder","hole","circular pattern","cut box"],"unsupported":0,"volume":17142.747554}, "21": {"area":5814.791707,"bodies":1,"edges":56,"error":null,"faces":22,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","join box","hole","fillet","hole","cut box"],"unsupported":1,"volume":19153.734422}, "22": {"area":4634.449436,"bodies":2,"edges":138,"error":null,"faces":54,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","join box","join box","join box","rectangular pattern","cut cylinder"],"unsupported":0,"volume":7855.479914}, "23": {"area":1795.696192,"bodies":1,"edges":16,"error":null,"faces":7,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":3198.694598}, @@ -72,7 +72,7 @@ "68": {"area":7338.675126,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","hole","hole","rectangular pattern","rectangular pattern","fillet","circular pattern"],"unsupported":0,"volume":29780.741772}, "69": {"area":6451.122767,"bodies":1,"edges":60,"error":"offset: a corner whose faces don't meet the same way after t","faces":27,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join box","shell","shell"],"unsupported":0,"volume":4761.477515}, "70": {"area":3682.669157,"bodies":1,"edges":24,"error":"lumps: shell boundaries intersect or touch; cavity ownership","faces":12,"outcome":"failed","rejected":0,"shells":2,"steps":["extruded profile","join box","hole"],"unsupported":1,"volume":9155.027162}, - "71": {"area":3980.574721,"bodies":1,"edges":52,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927796}, + "71": {"area":3980.574721,"bodies":1,"edges":50,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927796}, "72": {"area":5770.270336,"bodies":2,"edges":44,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["box","join cylinder","hole","join box","join box","cut box","cut cylinder","cut cylinder"],"unsupported":0,"volume":16081.801492}, "73": {"area":6868.162117,"bodies":1,"edges":40,"error":"offset: a corner whose faces don't meet the same way after t","faces":18,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","join box","shell"],"unsupported":1,"volume":36461.939381}, "74": {"area":9294.57311,"bodies":1,"edges":18,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole"],"unsupported":0,"volume":55866.183074}, @@ -95,7 +95,7 @@ "91": {"area":3958.046518,"bodies":1,"edges":50,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","fillet"],"unsupported":0,"volume":9721.244548}, "92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292}, "93": {"area":7266.404017,"bodies":7,"edges":111,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171}, - "94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, + "94": {"area":3480.70333,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, "95": {"area":2425.131626,"bodies":1,"edges":19,"error":"boolean Cut: the operation failed: no result; the operation ","faces":9,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","hole","fillet","cut cylinder","cut sphere"],"unsupported":0,"volume":7106.301922} }, "notes": [ @@ -129,8 +129,10 @@ {"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]}, {"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]}, {"reason":"#726 (#1687): a full revolve of a profile off the axis comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs. Replaying each new design's commands on main and on this branch: seeds 45, 77 and 94 end the same; in seed 21 a box joined onto the ring's flat top now fuses with it, where main kept two touching shells that a later hole split into two bodies.","seeds":[21,45,77,94]}, - {"reason":"#726 (#1718): a partial revolve made as a new body comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs (seed 24's shell now picks the centroid of a whole 270 deg annulus, which lies in the bore, so it is refused and the run goes on). Replaying each new design's commands on main and on this branch: 12 seeds end the same; in seeds 13 and 26 a box joined onto a flat revolved face now fuses with it (main kept two touching shells that a later step split into two bodies), seed 15's last cylinder join, which main refuses, now builds, and seed 78's cut box no longer leaves 87.5 mm² of extra area (the end cap's); seed 63 refuses the same fillet on both and ends with nearly the same body. Seed 9 now ends ok instead of failing, but its new design ends the same on main. No seed gains a failure.","seeds":[9,10,13,15,17,19,22,24,25,26,30,63,75,76,78,90,91]} -, + {"reason":"#726 (#1718): a partial revolve made as a new body comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs (seed 24's shell now picks the centroid of a whole 270 deg annulus, which lies in the bore, so it is refused and the run goes on). Replaying each new design's commands on main and on this branch: 12 seeds end the same; in seeds 13 and 26 a box joined onto a flat revolved face now fuses with it (main kept two touching shells that a later step split into two bodies), seed 15's last cylinder join, which main refuses, now builds, and seed 78's cut box no longer leaves 87.5 mm² of extra area (the end cap's); seed 63 refuses the same fillet on both and ends with nearly the same body. Seed 9 now ends ok instead of failing, but its new design ends the same on main. No seed gains a failure.","seeds":[9,10,13,15,17,19,22,24,25,26,30,63,75,76,78,90,91]}, + {"reason":"Current main replay: seed 55 now meshes every face and passes validity; edge-consistent measuring meshes (#1753) change its measured volume from 24741.239331 to 24740.927259 mm3. Seed 71 joins with one fewer redundant face and two fewer edges (19/50); its five steps, successful outcome, volume and area are unchanged within the existing 1e-6 tolerances. Both fixed command sequences were replayed with validity checks after every step.","seeds":[55,71]}, + {"reason":"Seed 20: trace conical cutters against earlier conical cuts at finer unshifted tolerances before coarse copies can leave unusable junctions. The circular pattern and final box cut both build; replayed in the app with no feature errors, unchanged 45 faces/116 edges/one shell, no tool nudge, and volume/spatial guards retained. The finer intersection curves change measured volume by 0.020503 mm3 and area by 0.171629 mm2.","seeds":[20]}, + {"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]}, {"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]} ] } diff --git a/tests/tools/test_icon_export.py b/tests/tools/test_icon_export.py new file mode 100644 index 00000000..18b08506 --- /dev/null +++ b/tests/tools/test_icon_export.py @@ -0,0 +1,142 @@ +"""Exercise the real HTTP export endpoint without touching the working review or queue.""" +import json +import csv +import pathlib +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.error +import urllib.request + + +class IconExportTest(unittest.TestCase): + def test_inventory_includes_nested_and_helper_icon_uses(self): + source = pathlib.Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools' + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + design = root / 'assets/icons/ui/design' + tools = design / 'tools' + tools.mkdir(parents=True) + for name in ('icon_status.py', 'review_state.py', 'icon_palette.py'): + shutil.copy(source / name, tools / name) + shutil.copytree(source / 'review', tools / 'review') + (design / 'palette.toml').write_text('[roles.line]\nlight="#000000"\ndark="#ffffff"\n') + for name in ('aliases.toml',): + (design / name).write_text('') + svg = design / 'svg/model' + svg.mkdir(parents=True) + for name in ('agent_chat', 'pattern_flip', 'normal', 'infinite', 'trace'): + (svg / (name + '.svg')).write_text('') + ui = root / 'crates/ui-egui/src' + (ui / 'agent_chat').mkdir(parents=True) + (ui / 'agent_chat/panel.rs').write_text('icons::paint(p, r, "agent_chat", ink, fill, a);') + (ui / 'dialogs.rs').write_text('pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);') + (ui / 'sketch_palette.rs').write_text(''' + for (i, (label, icon, tip)) in [ + ("—", "normal", "Normal"), + ("↔", "infinite", "Infinite"), + ].iter().enumerate() { icons::paint(p, r, icon, ink, fill, a); } + ''') + engine = root / 'crates/engine/src/cmd' + engine.mkdir(parents=True) + # A long builder must not lose its icon after the first fourteen lines. + (engine / 'motion.rs').write_text('CommandSpec::new("motion.trace", "Trace", trace)\n' + + '\n' * 16 + '.icon("trace").params("doc");') + subprocess.run([sys.executable, '-B', str(tools / 'icon_status.py'), '--csv'], + check=True, capture_output=True, text=True) + with open(root / 'target/icon-inventory.csv') as f: + rows = {r['icon']: r for r in csv.DictReader(f)} + self.assertEqual(set(rows), {'agent_chat', 'pattern_flip', 'normal', 'infinite', 'trace'}) + self.assertIn('agent_chat/panel.rs', rows['agent_chat']['used_by_ui']) + self.assertIn('dialogs.rs', rows['pattern_flip']['used_by_ui']) + for name in ('normal', 'infinite'): + self.assertIn('sketch_palette.rs', rows[name]['used_by_ui']) + self.assertEqual(rows['trace']['example_command'], 'motion.trace') + + def test_export_snapshots_only_pending_notes_for_codex(self): + source = pathlib.Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools' + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + design = root / 'assets/icons/ui/design' + tools = design / 'tools' + tools.mkdir(parents=True) + for name in ('icon_status.py', 'review_state.py', 'icon_palette.py'): + shutil.copy(source / name, tools / name) + shutil.copytree(source / 'review', tools / 'review') + for name in ('palette.toml', 'aliases.toml'): + (design / name).write_text('') + (design / 'palette.toml').write_text('[roles.line]\nlight="#000000"\ndark="#ffffff"\n') + svg = design / 'svg/model' + svg.mkdir(parents=True) + for name in ('box', 'joint'): + (svg / (name + '.svg')).write_text('') + # An accepted icon's note must never be exported, even if it still has text. + original = {'final': {'box': ''}, 'notes': {'box': 'Keep it', 'joint': 'Round the corners\nUse related icons'}} + (design / 'review.json').write_text(json.dumps(original)) + with socket.socket() as port_socket: + port_socket.bind(('127.0.0.1', 0)) + port = port_socket.getsockname()[1] + proc = subprocess.Popen([sys.executable, str(tools / 'icon_status.py'), '--serve', str(port)], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + url = 'http://127.0.0.1:%d' % port + for attempt in range(100): + try: + with urllib.request.urlopen(url + '/api/review', timeout=1) as response: + base = json.load(response) + break + except urllib.error.URLError: + if proc.poll() is not None: + self.fail(proc.stderr.read().decode()) + time.sleep(0.02) + else: + self.fail('review server did not start') + + def post(endpoint, state): + data = json.dumps({'base': base, **state}).encode() + with urllib.request.urlopen(urllib.request.Request(url + endpoint, data=data), timeout=2) as response: + return json.load(response) + + state = {'final': base['final'], 'notes': base['notes']} + inbox = root / 'target/codex-icon-review/inbox' + post('/api/review', state) + self.assertFalse(inbox.exists(), 'autosave must not request redraws') + result = post('/api/export', state) + self.assertTrue(result['ok']) + self.assertEqual(result['sent'], 1) + requests = list(inbox.glob('*.json')) + self.assertEqual(len(requests), 1) + request = json.loads(requests[0].read_text()) + self.assertEqual(request['id'], result['request_id']) + self.assertEqual(request['notes'], {'joint': original['notes']['joint']}) + self.assertFalse(list(inbox.glob('*.tmp'))) + self.assertFalse((root / 'target/review-requests.log').exists()) + with urllib.request.urlopen(url, timeout=2) as response: + page = response.read().decode() + self.assertIn('local review inbox', page) + self.assertNotIn('Claude', page) + self.assertIn('location.reload()', page) + # Existing tabs also reload when the server's page implementation changes. + with open(tools / 'icon_status.py', 'a') as f: + f.write('\n# page update\n') + with urllib.request.urlopen(url + '/api/review', timeout=2) as response: + self.assertNotEqual(json.load(response)['rev'], base['rev']) + # A note resolved on disk cannot be resurrected by an unchanged stale page. + saved = json.loads((design / 'review.json').read_text()) + saved['notes'] = {'box': 'Keep it'} + (design / 'review.json').write_text(json.dumps(saved)) + empty = post('/api/export', state) + self.assertEqual(empty['sent'], 0) + self.assertEqual(len(list(inbox.glob('*.json'))), 1) + finally: + proc.terminate() + proc.wait(timeout=5) + proc.stderr.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/tools/test_icon_review.py b/tests/tools/test_icon_review.py new file mode 100644 index 00000000..a61d8b34 --- /dev/null +++ b/tests/tools/test_icon_review.py @@ -0,0 +1,331 @@ +"""Long-term discovery and acceptance behavior of the repository-owned review app.""" +import contextlib +import json +import re +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import tomllib +import unittest +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +TOOLS = Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools' +SVG = '' + + +@contextlib.contextmanager +def review_fixture(): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + design = root / 'assets/icons/ui/design' + shutil.copytree(TOOLS, design / 'tools') + (design / 'svg/model').mkdir(parents=True) + (design / 'svg/model/box.svg').write_text(SVG) + shutil.copy2(TOOLS.parent / 'palette.toml', design / 'palette.toml') + (design / 'references.toml').write_text('[category.model]\nlabel="Model"\nrule="Standard projection"\nicons=["box"]\n') + (design / 'aliases.toml').write_text('[alias]\nbox_alias="box"\n') + (design / 'review.json').write_text(json.dumps({'final': {'box': ''}, 'notes': {}})) + with socket.socket() as available: + available.bind(('127.0.0.1', 0)) + port = available.getsockname()[1] + process = subprocess.Popen([sys.executable, '-B', str(design / 'tools/icon_status.py'), '--serve', str(port)], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + url = f'http://127.0.0.1:{port}' + def get(path): + with urllib.request.urlopen(url + path, timeout=3) as response: + data = response.read().decode() + return json.loads(data) if path == '/api/review' else data + def post(state, final=None, notes=None, endpoint='/api/review', rev=None): + payload = state if endpoint == '/api/palette' else { + 'base': state, 'final': state['final'] if final is None else final, + 'notes': state['notes'] if notes is None else notes, 'rev': rev or state['rev']} + try: + with urllib.request.urlopen(urllib.request.Request(url + endpoint, data=json.dumps(payload).encode()), timeout=3) as response: + return json.load(response) + except urllib.error.HTTPError as error: + error.close() + raise + try: + for attempt in range(100): + try: + get('/api/review'); break + except urllib.error.URLError: + if process.poll() is not None: raise RuntimeError(process.stderr.read().decode()) + time.sleep(.02) + else: raise RuntimeError('server did not start') + yield root, design, get, post + finally: + process.terminate(); process.wait(timeout=5); process.stderr.close() + + +class IconReviewTest(unittest.TestCase): + def test_inline_clips_and_masks_are_scoped_to_each_preview_and_dashes_use_guides(self): + with review_fixture() as (root, design, get, post): + for name in ('environment_map', 'new', 'draft', 'bounding_solid', 'extend'): + shutil.copy2(TOOLS.parent / f'svg/model/{name}.svg', design / f'svg/model/{name}.svg') + with (design / 'aliases.toml').open('a') as aliases: + aliases.write('surface_env={master="environment_map",accent="surface"}\n') + page = get('/') + fragments = re.findall(r'', page, re.S) + seen = set() + for svg in fragments: + ids = re.findall(r'\bid="([^"]+)"', svg) + self.assertTrue(seen.isdisjoint(ids), 'all theme/vision/alias previews need unique ids') + seen.update(ids) + for ref in re.findall(r'url\(#([^)]+)\)', svg): self.assertIn(ref, ids, 'clip or mask must belong to this SVG') + self.assertGreater(len(seen), 10) + for svg in fragments: + tree = ET.fromstring(svg) + mask_nodes = {id(node) for mask in tree.iter('{http://www.w3.org/2000/svg}mask') for node in mask.iter()} + for node in tree.iter(): + if not node.get('stroke-dasharray'): continue + stroke = node.get('stroke', 'none') + if stroke == 'none': continue + if id(node) in mask_nodes and stroke == 'black': continue + self.assertTrue(any(role in stroke for role in ('roles-guide-', 'active-3-')), stroke) + self.assertNotIn('var(--icon-palette-roles-gap-', svg) + self.assertIn('review-environment_map-off-dark-full-disc-env', page) + def card(icon): return page.split('data-id="' + icon + '"', 1)[1].split('
', 1)[0] + self.assertIn('stroke="var(--icon-palette-roles-active-3-', card('bounding_solid')) + self.assertIn('stroke="var(--icon-palette-accents-sketch-active-3-', card('extend')) + self.assertIn('stroke="var(--icon-palette-roles-guide-', card('draft')) + + def test_live_palette_tokens_save_only_display_colours_and_reject_stale_or_invalid_changes(self): + with review_fixture() as (root, design, get, post): + palette_path = design / 'palette.toml' + original = palette_path.read_text() + palette = tomllib.loads(original) + state = json.loads(get('/api/palette')) + tokens = {token['id']: token for token in state['tokens']} + self.assertIn('roles.ink', tokens) + self.assertIn('accents.surface.active-2', tokens) + self.assertIn('variants.red-green.sketch.active-3', tokens) + self.assertNotIn('accents.construction.guide', tokens, 'guides have one shared colour') + self.assertNotIn('roles.ink.source', tokens) + self.assertTrue(tokens['roles.gap']['knockout']) + accepted = (design / 'review.json').read_bytes() + artwork = (design / 'svg/model/box.svg').read_bytes() + def save(revision, changes): + return post({'revision': revision, 'changes': changes}, endpoint='/api/palette') + replacement = '#123456' if tokens['roles.ink']['dark'] != '#123456' else '#654321' + paints = {'light': tokens['roles.ink']['light'], 'dark': replacement} + changes = {'roles.ink': paints, + 'accents.surface.active-2': {'light': tokens['accents.surface.active-2']['light'], 'dark': '#bc789a'}, + 'variants.red-green.sketch.active-3': {'light': tokens['variants.red-green.sketch.active-3']['light'], 'dark': '#778899'}} + saved = save(state['revision'], changes) + self.assertTrue(saved['ok']) + expected = tomllib.loads(original) + for key, colours in changes.items(): + target = expected + for part in key.split('.'): target = target[part] + target.update(colours) + self.assertEqual(tomllib.loads(palette_path.read_text()), expected) + self.assertIn('# Icon colour roles.', palette_path.read_text(), 'comments remain intact') + self.assertEqual(tomllib.loads(palette_path.read_text())['roles']['ink']['source'], palette['roles']['ink']['source']) + for revision, changes in [ + (state['revision'], {'roles.ink': paints}), + (saved['revision'], {'roles.ink.source': paints}), + (saved['revision'], {'accents.construction.guide': paints}), + (saved['revision'], {'roles.gap': paints}), + (saved['revision'], {'roles.ink': {'light': 'url(evil)', 'dark': replacement}}), + ]: + before = palette_path.read_bytes() + with self.assertRaises(urllib.error.HTTPError): save(revision, changes) + self.assertEqual(palette_path.read_bytes(), before) + page = get('/') + self.assertIn('var(--icon-palette-roles-line-light,', page) + self.assertIn('--icon-palette-roles-ink-dark:' + replacement, page) + self.assertIn('id="palette-tokens"', page) + self.assertIn('input.oninput', page) + self.assertIn('body.palette-preview .card.final .art{opacity:1}', page) + self.assertEqual((design / 'review.json').read_bytes(), accepted) + self.assertEqual((design / 'svg/model/box.svg').read_bytes(), artwork) + self.assertFalse(list(design.glob('*.tmp'))) + + def test_knockouts_mask_only_preceding_layers_and_preserve_later_foreground(self): + with review_fixture() as (root, design, get, post): + source = '''''' + path = design / 'svg/model/cutout.svg' + path.write_text(source) + accepted = (design / 'review.json').read_bytes() + page = get('/') + fragments = [svg for svg in re.findall(r'', page, re.S) if '-base"' in svg] + self.assertGreater(len(fragments), 3) + ns = '{http://www.w3.org/2000/svg}' + for svg in fragments: + tree = ET.fromstring(svg) + mask = tree.find(ns + 'defs/' + ns + 'mask') + self.assertIsNotNone(mask) + self.assertEqual(mask.get('maskUnits'), 'userSpaceOnUse') + self.assertEqual(mask[0].get('fill'), 'white') + self.assertIn('-left)', mask[1].get('clip-path')) + self.assertEqual(mask[1][0].get('fill'), 'black') + preceding, foreground = list(tree)[-2:] + self.assertEqual(preceding.get('mask'), 'url(#' + mask.get('id') + ')') + self.assertTrue(preceding[0].get('id').endswith('-base')) + self.assertTrue(foreground.get('id').endswith('-foreground')) + self.assertIsNone(foreground.get('mask')) + self.assertNotIn('roles-gap-', svg) + self.assertEqual(path.read_text(), source) + self.assertEqual((design / 'review.json').read_bytes(), accepted) + + def test_semantic_colours_reload_from_one_palette_without_changing_acceptance(self): + with review_fixture() as (root, design, get, post): + palette = design / 'palette.toml' + colours = tomllib.loads(palette.read_text()) + source = colours['roles']['active-3'].get('source', colours['roles']['active-3']['light']) + art = SVG.replace('currentColor', source) + for name in ('line', 'c_fix'): + (design / f'svg/model/{name}.svg').write_text(art) + with (design / 'aliases.toml').open('a') as aliases: + aliases.write('ground="c_fix"\nsurface_line={master="line",accent="surface"}\n') + state, accepted = get('/api/review'), (design / 'review.json').read_bytes() + def card(page, icon): + return page.split('data-id="' + icon + '"', 1)[1].split('
', 1)[0] + page = get('/') + self.assertIn(colours['accents']['sketch']['active-3']['light'], card(page, 'line')) + self.assertIn(colours['variants']['red-green']['sketch']['active-3']['dark'], card(page, 'line'), + 'red/green vision shares the application ramp') + self.assertIn(colours['accents']['constraint']['active-3']['light'], card(page, 'c_fix')) + self.assertIn(colours['roles']['active-3']['light'], card(page, 'ground'), + 'a shared master does not transfer its meaning to an alias') + self.assertIn(colours['accents']['surface']['active-3']['light'], card(page, 'surface_line'), + 'explicit alias accent wins over the Sketch master') + def set_light(section, colour): + lines, current = palette.read_text().splitlines(), '' + for index, line in enumerate(lines): + if line.strip().startswith('['): + current = line.strip().split(']', 1)[0] + ']' + elif current == '[' + section + ']' and line.split('=', 1)[0].strip() == 'light': + lines[index] = 'light = ' + json.dumps(colour) + palette.write_text('\n'.join(lines) + '\n') + return + self.fail('missing palette section ' + section) + replacement = '#123456' if colours['accents']['sketch']['active-3']['light'] != '#123456' else '#654321' + set_light('accents.sketch.active-3', replacement) + self.assertNotEqual(state['rev'], get('/api/review')['rev']) + self.assertIn(replacement, card(get('/'), 'line'), 'a running server reads the updated palette') + base = '#345678' if colours['roles']['active-3']['light'] != '#345678' else '#876543' + set_light('roles.active-3', base) + self.assertIn(base, card(get('/'), 'ground'), 'source identifiers survive changing the base display palette too') + self.assertEqual((design / 'review.json').read_bytes(), accepted) + self.assertEqual((design / 'svg/model/line.svg').read_text(), art) + + def test_new_unused_masters_and_alias_targets_are_never_hidden(self): + with review_fixture() as (root, design, get, post): + original = get('/api/review') + page = get('/') + self.assertIn('data-id="box"', page, 'unused alias target must still have a card') + self.assertIn('data-id="box_alias"', page) + (design / 'svg/model/brand_new.svg').write_text(SVG) + current = get('/api/review') + self.assertNotEqual(original['rev'], current['rev']) + self.assertNotIn('brand_new', current['final']) + page = get('/') + self.assertIn('data-id="brand_new"', page) + self.assertIn("let filter = 'open', tab = ''", page) + self.assertNotIn("const VK='icon-view'", page) + self.assertIn('cards.length - finals', page, 'notes remain a subset of Open') + post(current, final=current['final'] + ['brand_new']) + self.assertIn('brand_new', get('/api/review')['final']) + # A new opening does not inherit a previous session's hidden tab/query. + self.assertIn('data-f="open" class="on"', get('/')) + + def test_changed_art_reopens_and_requires_deliberate_current_acceptance(self): + with review_fixture() as (root, design, get, post): + initial = get('/api/review') + self.assertIn('box', initial['final']) + old_hash = json.loads((design / 'review.json').read_text())['final']['box'] + (design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="18"')) + changed = get('/api/review') + self.assertNotIn('box', changed['final']) + self.assertIn('changed since acceptance', get('/')) + self.assertEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box']) + (design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="17"')) + with self.assertRaises(urllib.error.HTTPError) as error: + post(changed, final=['box']) + self.assertEqual(error.exception.code, 400) + post(get('/api/review'), final=['box']) + self.assertIn('box', get('/api/review')['final']) + self.assertNotEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box']) + + def test_missing_art_is_visible_but_cannot_be_accepted(self): + with review_fixture() as (root, design, get, post): + commands = root / 'crates/engine/src/cmd' + commands.mkdir(parents=True) + (commands / 'example.rs').write_text('CommandSpec::new("example.action", "Example", run).icon("missing");') + page = get('/') + self.assertIn('data-id="missing"', page) + self.assertIn('aria-label="Accept missing as final" disabled', page) + with self.assertRaises(urllib.error.HTTPError): + post(get('/api/review'), final=['box', 'missing']) + + def test_final_alias_protects_shared_art_and_newer_notes_survive_stale_save(self): + with review_fixture() as (root, design, get, post): + state = get('/api/review') + post(state, final=['box', 'box_alias']) + state = get('/api/review') + post(state, final=['box_alias'], notes={'box': 'Unreviewed master note'}) + state = get('/api/review') + self.assertEqual(post(state, endpoint='/api/export')['sent'], 0) + # A stale page changes another note; it must not resurrect replaced feedback. + saved = json.loads((design / 'review.json').read_text()) + saved['notes']['box'] = 'Newer feedback' + (design / 'review.json').write_text(json.dumps(saved)) + post(state, notes={**state['notes'], 'box_alias': 'Another note'}) + self.assertEqual(get('/api/review')['notes']['box'], 'Newer feedback') + self.assertFalse(list(design.glob('*.tmp'))) + + def test_export_resolution_preserves_notes_added_since_the_snapshot(self): + with review_fixture() as (root, design, get, post): + (design / 'svg/model/fresh.svg').write_text(SVG) + state = get('/api/review') + post(state, notes={'fresh': 'Newer feedback'}) + script = """ +import sys +sys.path.insert(0,sys.argv[1]) +import review_state +request={'notes':{'fresh':'Exported feedback'}} +assert review_state.resolve_export(request, {'fresh'}) == [] +assert review_state.load()['notes']['fresh'] == 'Newer feedback' +request={'notes':{'fresh':'Newer feedback'}} +assert review_state.resolve_export(request, {'fresh'}) == ['fresh'] +assert review_state.load()['notes'] == {} +""" + subprocess.run([sys.executable, '-B', '-c', script, str(design / 'tools')], check=True) + + def test_style_reference_page_uses_repository_bases(self): + with review_fixture() as (root, design, get, post): + page = get('/guide') + self.assertIn('Canonical grey cube', page) + self.assertIn('Canonical grey slab', page) + self.assertIn('Standard projection', page) + self.assertIn('STYLE_GUIDE.md', page) + + def test_scaffold_refuses_overwrite_and_starts_unaccepted(self): + with review_fixture() as (root, design, get, post): + # The fixture's palette is intentionally minimal; the scaffold uses + # the real helper constants and emits an editable starter SVG. + (root / 'ATTRIBUTION.md').write_text('# Attribution\n') + command = [sys.executable, '-B', str(design / 'tools/author/new_icon.py')] + first = subprocess.run(command + ['fresh_shape', '--base', 'cube'], capture_output=True, text=True) + self.assertEqual(first.returncode, 0, first.stderr) + artwork = design / 'svg/model/fresh_shape.svg' + self.assertTrue(artwork.exists()) + self.assertNotIn('fresh_shape', get('/api/review')['final']) + self.assertIn('fresh_shape.svg', (root / 'ATTRIBUTION.md').read_text()) + before = artwork.read_bytes() + again = subprocess.run(command + ['fresh_shape'], capture_output=True, text=True) + self.assertNotEqual(again.returncode, 0) + self.assertEqual(artwork.read_bytes(), before) + alias = subprocess.run(command + ['box_alias'], capture_output=True, text=True) + self.assertNotEqual(alias.returncode, 0) + + +if __name__ == '__main__': unittest.main() diff --git a/xtask/src/icons.rs b/xtask/src/icons.rs new file mode 100644 index 00000000..e3f61858 --- /dev/null +++ b/xtask/src/icons.rs @@ -0,0 +1,16 @@ +//! Original UI icon standards and review-tool regression checks. +use std::path::Path; +use std::process::Command; + +pub fn run(root: &Path) -> Result<(), String> { + for args in [ + vec!["-B", "assets/icons/ui/design/tools/lint_icons.py"], + vec!["-B", "assets/icons/ui/design/tools/icon_status.py", "--check"], + vec!["-B", "-m", "unittest", "discover", "-s", "tests/tools", "-p", "test_icon*.py"], + ] { + let mut command = Command::new("python3"); + command.args(args).current_dir(root); + super::run(command, "UI icon standards (Python 3.11+)")?; + } + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8d009c63..ae7ded72 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,13 +1,14 @@ //! Workspace tooling: `cargo xtask `. //! -//! Pure Rust (std, serde_json and sha2). External tools (`cargo`) are invoked through -//! `std::process::Command`. +//! Rust workspace tooling. External tools (`cargo`, and Python 3.11+ for the +//! original icon collection) are invoked through `std::process::Command`. mod app_icons; mod assets; mod book; mod features; mod i18n; +mod icons; mod layers; mod licences; mod lists; @@ -25,6 +26,7 @@ commands: layers enforce the crate dependency layering (plan/architecture.md §3) assets check that every non-code asset file is attributed in ATTRIBUTION.md app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows + icons validate UI icon standards, discovery and review-tool behavior (Python 3.11+) features list SolidCraft's commands by area in docs/features.md (from the registry) i18n [--check | --add-missing] report how complete the message catalogs in assets/locales are: @@ -61,7 +63,7 @@ commands: web-offline-test [--site DIR] check in headless Chrome that the web app works offline and updates to a new build (a stand-in app, or the web build in DIR such as dist/web) - ci fmt --check, assets, licences --check, lists, layers, clippy -D warnings, then the tests + ci fmt --check, assets, icons, licences --check, lists, layers, clippy -D warnings, then the tests with the wasm check alongside, then web-offline-test when Chrome is installed (stops at the first failing step) "; @@ -72,6 +74,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("icons") => icons::run(&root()), Some("app-icons") => app_icons::run(&root()), Some("features") => features::run(&root()), Some("i18n") => i18n::run(&root(), &rest), @@ -362,6 +365,7 @@ fn cmd_ci() -> Result<(), String> { c.args(["fmt", "--all", "--", "--check"]); run(c, "cargo fmt --check")?; assets::run(&root())?; + icons::run(&root())?; licences::run(&root(), true)?; lists::run(&root(), false)?; cmd_layers()?; From f350c35414be0062fa6aaa464ab2409789898fce Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:19 +0200 Subject: [PATCH 085/149] Expressions: a mixed number such as 2 1/8" is 2.125" (#1802) (#1820) Thanks @SychPL. --- crates/doc/src/expr.rs | 69 +++++++++++++++++++++++++++++++ docs/book/src/guide/parameters.md | 4 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index cb4b4d56..95f3958f 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -359,6 +359,15 @@ fn lex_spans(s: &str) -> Result, DocError> { } let t = s.get(at(st)..at(i)).unwrap_or_default(); let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?; + // A mixed number, as imperial sizes are written (`2 1/8"` is 2.125 in, #1802): a whole + // number, then a proper fraction of whole numbers, is one value, not `2 × 1/8`. + if t.chars().all(|c| c.is_ascii_digit()) + && let Some((fraction, end)) = mixed_fraction(&cs, i, depth) + { + out.push((Tok::Num(v + fraction), at(st), at(end))); + i = end; + continue; + } out.push((Tok::Num(v), at(st), at(i))); } else if c.is_alphabetic() || c == '_' || c == 'µ' { let st = i; @@ -404,6 +413,43 @@ fn lex_spans(s: &str) -> Result, DocError> { Ok(out) } +/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`, +/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a +/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the +/// parentheses open there (inside them a comma separates arguments: `max(2 1/8,1)`). +fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f64, usize)> { + let digits = |from: usize| cs.get(from..).map_or(0, |rest| rest.iter().take_while(|(_, c)| c.is_ascii_digit()).count()); + let space = i; + while cs.get(i).is_some_and(|(_, c)| *c == ' ' || *c == '\t') { + i += 1; + } + let numerator = digits(i); + if i == space || numerator == 0 || !cs.get(i + numerator).is_some_and(|(_, c)| *c == '/') { + return None; + } + let denominator = digits(i + numerator + 1); + let end = i + numerator + 1 + denominator; + let next = |k: usize| cs.get(end + k).map(|(_, c)| *c); + let continues = matches!(next(0), Some('.')) + || (depth == 0 && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit())) + || (matches!(next(0), Some('e' | 'E')) && next(1).is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+')); + if denominator == 0 || continues { + return None; + } + // Compared as digits, not as floats: long ones round to the same float (#1802 review). + let digits_of = |from: usize, len: usize| -> Option { + let s: String = cs.get(from..from + len)?.iter().map(|(_, c)| *c).collect(); + Some(s.trim_start_matches('0').to_string()) + }; + let (n, d) = (digits_of(i, numerator)?, digits_of(i + numerator + 1, denominator)?); + let proper = !d.is_empty() && (n.len(), n.as_str()) < (d.len(), d.as_str()); + let value = |t: &str| if t.is_empty() { Some(0.0) } else { t.parse::().ok() }; + if !proper { + return None; + } + Some((value(&n)? / value(&d)?, end)) +} + fn lex(s: &str) -> Result, DocError> { Ok(lex_spans(s)?.into_iter().map(|(t, _, _)| t).collect()) } @@ -965,6 +1011,29 @@ mod tests { assert_eq!(ev("\"", Kind::Length).ok(), ev("in", Kind::Length).ok()); } + #[test] + fn mixed_numbers() { + // #1802: `2 1/8"` is 2.125 in, as imperial sizes are written, not 2 × 1/8 in. + assert_eq!(ev("2 1/8\"", Kind::Length).unwrap(), 2.125 * 25.4); + assert_eq!(ev("2 1/8 in", Kind::Length).unwrap(), 2.125 * 25.4); + assert_eq!(ev("3 7/16\"", Kind::Length).unwrap(), 3.4375 * 25.4); + assert_eq!(ev("10 1/2mm", Kind::Length).unwrap(), 10.5); + assert_eq!(ev("width + 1 1/2", Kind::Length).unwrap(), 41.5); + assert_eq!(ev("max(2 1/8, 3)", Kind::Unitless).unwrap(), 3.0); + // Inside parentheses a comma after the fraction separates arguments; outside, a comma + // between digits is a decimal comma, which continues the number (as before). + assert_eq!(ev("max(2 1/8,1)", Kind::Unitless).unwrap(), 2.125); + assert_eq!(ev("2 1/8,5", Kind::Unitless).unwrap(), 2.0 / 8.5); + // Long numerators and denominators are compared as digits, not as rounded floats. + assert!((ev("2 9007199254740992/9007199254740993", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12); + assert_eq!(ev("2 007/8", Kind::Unitless).unwrap(), 2.875); + assert_eq!(ev("-2 1/4", Kind::Unitless).unwrap(), -2.25); + // Not a proper fraction of whole numbers after a whole number: as before. + for (s, v) in [("2 3/2", 3.0), ("2.5 1/2", 1.25), ("2 1/8.5", 2.0 / 8.5), ("2 1 / 8", 0.25), ("2 1/0", f64::INFINITY), ("1/8", 0.125)] { + assert_eq!(ev(s, Kind::Unitless).ok(), Some(v).filter(|v| v.is_finite()), "{s}"); + } + } + #[test] fn arithmetic_and_units() { assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index f9b43dc4..5aa1f18a 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -19,7 +19,9 @@ Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in `+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod trunc` and unit conversion. A decimal comma works too, outside parentheses: `2,5 mm` is `2.5 mm` (inside a function's parentheses the comma separates its arguments, so write -`max(2.5, 3)` there). A length where an angle is expected (or the other way round) is an +`max(2.5, 3)` there). A mixed number is one value, as imperial sizes are written: `2 1/8"` and +`3 7/16 in` are 2.125 in and 3.4375 in (a whole number, a space, then a proper fraction of whole +numbers). A length where an angle is expected (or the other way round) is an error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a parameter updates every expression, dimension and feature input that uses it, and a parameter in use can't be deleted (the error lists its users). From 337564190b49c35592b8fc4e46afe198bd9ee7ff Mon Sep 17 00:00:00 2001 From: Thodoris Tsagkogeorgas Date: Sat, 17 Oct 2026 11:07:23 +0300 Subject: [PATCH 086/149] Midplane asks for the two planes (#1845) (#1846) Thanks @thodoristsagko. --- crates/ui-egui/src/dialogs.rs | 53 +++++++++++++++++++++++---- crates/ui-egui/src/preselect_tests.rs | 23 ++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 139e10a4..31dbb142 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -114,6 +114,8 @@ pub enum Kind { OffsetPlane { offset: String, }, + /// Halfway between two planes or planar faces. + Midplane, /// A construction plane through an axis at an angle to a plane. AnglePlane { angle: String, @@ -521,6 +523,10 @@ impl Dialog { "construct.plane.offset" => { Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } + "construct.plane.midplane" => Dialog::new( + Kind::Midplane, + vec![SelInput::new("First plane", PLANES | PLANAR_FACES, false), SelInput::new("Second plane", PLANES | PLANAR_FACES, false)], + ), "construct.plane.along_path" => { Dialog::new(Kind::PathPlane { t: "0.5".into(), sketch: None }, vec![SelInput::new("Path", CURVES | EDGES, false)]) } @@ -790,6 +796,7 @@ impl Dialog { | Kind::Loft { .. } | Kind::Sweep { .. } | Kind::PathPlane { .. } + | Kind::Midplane | Kind::Move { .. } | Kind::Hole { .. } | Kind::Primitive { .. } @@ -1379,6 +1386,7 @@ fn title(k: &Kind) -> &'static str { Kind::Measure { .. } => "MEASURE", Kind::Material { .. } => "PHYSICAL MATERIAL", Kind::OffsetPlane { .. } => "OFFSET PLANE", + Kind::Midplane => "MIDPLANE", Kind::PathPlane { .. } => "PLANE ALONG PATH", Kind::Rib { web: false, .. } => "RIB", Kind::Rib { web: true, .. } => "WEB", @@ -2686,7 +2694,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { enter |= field(ui, t); ui.end_row(); } - Kind::Split | Kind::ReplaceFace | Kind::Remove => {} + Kind::Split | Kind::ReplaceFace | Kind::Remove | Kind::Midplane => {} Kind::Rib { thickness, depth, flip, .. } => { row_label(ui, "Thickness"); enter |= field(ui, thickness); @@ -3897,15 +3905,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } Kind::OffsetPlane { offset } => { need(0, "a plane")?; - let base = match sels(d, 0).first() { - Some(Sel::Plane { name }) => json!(name), - Some(face @ Sel::Face { .. }) => { - crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model")? - } - _ => return Err("pick an origin or construction plane, or a planar face".into()), - }; + let base = plane_arg(app, sels(d, 0).first())?; ("construct.plane.offset", json!({"base": base, "offset": offset})) } + Kind::Midplane => { + need(0, "the first plane")?; + need(1, "the second plane")?; + let a = plane_arg(app, sels(d, 0).first())?; + let b = plane_arg(app, sels(d, 1).first())?; + ("construct.plane.midplane", json!({"a": a, "b": b})) + } Kind::PathPlane { t, sketch } => { need(0, "a path")?; let path = match sels(d, 0).first() { @@ -4495,6 +4504,24 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solidcraft_engine::doc::E Some(Sel::Face { body: world_name, index, point }) } +/// A picked plane or planar face as `construct.plane.*` takes it. +fn plane_arg(app: &SolveApp, sel: Option<&Sel>) -> Result { + match sel { + Some(Sel::Plane { name }) => Ok(json!(name)), + Some(face @ Sel::Face { .. }) => crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model".into()), + _ => Err("pick an origin or construction plane, or a planar face".into()), + } +} + +/// A stored construction reference back into a dialog pick. +fn geo_sel(s: &Session, g: &solidcraft_engine::doc::construct::GeoRef) -> Option { + match g { + solidcraft_engine::doc::construct::GeoRef::Plane { plane } => plane_sel(s, plane), + solidcraft_engine::doc::construct::GeoRef::Face { at, .. } => face_sel(s, *at), + _ => None, + } +} + pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } => Some(Sel::Plane { name: name.clone() }), @@ -5022,6 +5049,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + let mut d = start("construct.plane.midplane")?; + if let Some(inp) = d.inputs.first_mut() { + inp.items = geo_sel(s, a).into_iter().collect(); + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = geo_sel(s, b).into_iter().collect(); + } + d + } FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => { let mut d = start("construct.plane.at_angle")?; d.kind = Kind::AnglePlane { angle: angle.to_string() }; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 7df2a879..c621c594 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -1649,3 +1649,26 @@ fn patch_dialog_rows_are_translated() { assert!(h.widget(through).is_some(), "{lang}: the points input must show {through:?}"); } } + +/// Midplane opens a two-plane dialog. Starting it with nothing selected used to run the +/// command and report that `a` is required. +#[test] +fn midplane_asks_for_two_planes_then_builds_the_one_between_them() { + let mut h = crate::scenario::Harness::new(); + h.app.run("construct.plane.offset", json!({"base":"XY","offset":"20 mm","name":"Shelf"})).unwrap(); + h.app.start("construct.plane.midplane"); + h.frames(2); + let d = h.app.dialog.as_ref().unwrap(); + assert!(matches!(d.kind, Kind::Midplane)); + assert_eq!(d.inputs.len(), 2); + let status = h.app.status.as_ref().map(|s| s.0.clone()).unwrap_or_default(); + assert!(!status.contains("is required"), "{status}"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::Plane { name: "XY".into() }]; + h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Plane { name: "Shelf".into() }]; + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone())); + let (_, _, pl) = + solidcraft_engine::view::construction_planes(&h.app.session).into_iter().find(|(_, name, _)| name != "Shelf").expect("the midplane"); + assert!(pl.normal().cross(Vec3::Z).len() < 1e-6, "{pl:?}"); + assert!((pl.origin.z - 10.0).abs() < 1e-6, "{pl:?}"); +} From f3b5846e6b81e4f2d02afee4d13e73f2e39b4f67 Mon Sep 17 00:00:00 2001 From: Thodoris Tsagkogeorgas Date: Sat, 17 Oct 2026 11:07:26 +0300 Subject: [PATCH 087/149] Escape stays in the sketch (#1804) (#1836) Thanks @thodoristsagko. --- crates/ui-egui/src/sketch_shortcut_tests.rs | 23 +++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 5 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/sketch_shortcut_tests.rs b/crates/ui-egui/src/sketch_shortcut_tests.rs index 5bd8403a..4a95a847 100644 --- a/crates/ui-egui/src/sketch_shortcut_tests.rs +++ b/crates/ui-egui/src/sketch_shortcut_tests.rs @@ -67,6 +67,29 @@ fn a_sketch_key_with_nothing_selected_asks_for_the_plane_then_starts_the_tool() assert!(h.app.dialog.is_none()); } +/// #1804: Escape puts down the sketch tool. Another Escape does not leave the sketch. +#[test] +fn a_second_escape_stays_in_the_sketch() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let sketch = h.app.session.active_sketch; + h.app.start("sketch.line"); + h.settle(); + assert_eq!(tool(&h), Some("sketch.line")); + press(&mut h, "Escape"); + assert!(tool(&h).is_none(), "the first Escape puts the tool down"); + assert_eq!(h.app.session.active_sketch, sketch); + press(&mut h, "Escape"); + assert_eq!(h.app.session.active_sketch, sketch, "a second Escape stays in the sketch"); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]]})).unwrap(); + h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into() }]; + press(&mut h, "Escape"); + assert!(h.app.session.selection.is_empty(), "Escape still clears a selection"); + assert_eq!(h.app.session.active_sketch, sketch); + press(&mut h, "Escape"); + assert_eq!(h.app.session.active_sketch, sketch, "Escape after clearing the selection stays in the sketch"); +} + #[test] fn escape_drops_the_tool_waiting_for_a_sketch() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c6d5a47d..c1bd3ecb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1320,10 +1320,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() { app.tree.picked_joints.clear(); let _ = app.run("select.clear", json!({})); - } else { - // Nothing left to cancel: Esc finishes the sketch. - app.finish_sketch(); } + // An open sketch with nothing left to cancel stays open (#1804). Finish Sketch + // is what leaves it, so a second Escape after putting a tool down is harmless. } Key::S if !mods.shift && !mods.alt => { let at = ctx.input(|i| i.pointer.latest_pos()); From 2abf4e5a7d3ca74380dfd01238efea21acb8fd98 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:29 +0200 Subject: [PATCH 088/149] STEP import: read HYPERBOLA and PARABOLA edges exactly (#1832) Thanks @NOWN-DEV. --- crates/kernel/src/step_in/conic_tests.rs | 197 +++++++++++++++++++++++ crates/kernel/src/step_in/geom.rs | 127 ++++++++++++++- crates/kernel/src/step_in/mod.rs | 2 + crates/kernel/src/step_out.rs | 10 +- docs/step-import.md | 2 +- 5 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 crates/kernel/src/step_in/conic_tests.rs diff --git a/crates/kernel/src/step_in/conic_tests.rs b/crates/kernel/src/step_in/conic_tests.rs new file mode 100644 index 00000000..f5942541 --- /dev/null +++ b/crates/kernel/src/step_in/conic_tests.rs @@ -0,0 +1,197 @@ +//! Hyperbola and parabola edges (ISO 10303-42 HYPERBOLA, PARABOLA): a cone cut by a plane +//! parallel to its axis meets it in a hyperbola, by a plane parallel to a generator in a +//! parabola. Hand-written files, volumes against closed forms. + +use std::f64::consts::{FRAC_PI_4, PI}; + +use mt::{BoundedCurve, InnerSpace, ParametricCurve}; +use truck_modeling as mt; + +use super::geom::{Frame, hyperbola_arc, parabola_arc}; +use super::*; +use crate::{measure, step_export}; + +const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n"; +const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n"; +const CONTEXT: &str = "#90=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#81),#91);\n#91=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#92))GLOBAL_UNIT_ASSIGNED_CONTEXT((#93,#94))REPRESENTATION_CONTEXT('',''));\n#92=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#93,'','');\n#93=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#94=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n"; + +/// Height of the cone pieces: the 45° cone `x² + y² = z²` up to `z = H`. +const H: f64 = 10.0; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) +} + +/// A solid bounded by the 45° cone (apex at the origin, axis Z), its top plane `z = H` and a +/// cutting plane. `curve` defines `#30` (the conic edge from `v1` to `v2`, rising parameter) and +/// `#41`, the cutting plane's placement (normal pointing out of the solid). The top circle runs +/// `#51` from `circle_from` to the other vertex counter-clockwise seen from above; the loops say +/// which way each face uses each edge. +fn cone_piece(curve: &str, v1: [f64; 3], v2: [f64; 3], circle_from_v1: bool, loops: [&str; 3]) -> String { + let mut t = String::from(HEAD); + t += "#1=CARTESIAN_POINT('',(0.,0.,10.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n"; + t += &format!("#5=CONICAL_SURFACE('',#4,10.,{FRAC_PI_4:.15});\n#6=PLANE('',#4);\n"); + t += &format!("#10=CARTESIAN_POINT('',({:.15},{:.15},{:.15}));\n#11=VERTEX_POINT('',#10);\n", v1[0], v1[1], v1[2]); + t += &format!("#12=CARTESIAN_POINT('',({:.15},{:.15},{:.15}));\n#13=VERTEX_POINT('',#12);\n", v2[0], v2[1], v2[2]); + t += curve; + t += "#31=EDGE_CURVE('',#11,#13,#30,.T.);\n#42=PLANE('',#41);\n"; + t += "#50=CIRCLE('',#4,10.);\n"; + t += if circle_from_v1 { "#51=EDGE_CURVE('',#11,#13,#50,.T.);\n" } else { "#51=EDGE_CURVE('',#13,#11,#50,.T.);\n" }; + t += "#52=VECTOR('',#3,1.);\n#53=LINE('',#10,#52);\n#54=EDGE_CURVE('',#11,#13,#53,.T.);\n"; + // Faces: the cone, the cutting plane, the top. + for (k, (lp, surface)) in loops.iter().zip(["#5", "#42", "#6"]).enumerate() { + let b = 60 + 6 * k; + let edges: Vec<&str> = lp.split(' ').collect(); + let mut oes = Vec::new(); + for (j, e) in edges.iter().enumerate() { + let (id, fwd) = e.split_at(e.len() - 1); + t += &format!("#{}=ORIENTED_EDGE('',*,*,{id},{});\n", b + j, if fwd == "+" { ".T." } else { ".F." }); + oes.push(format!("#{}", b + j)); + } + t += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", b + 3, oes.join(","), b + 4, b + 3); + t += &format!("#{}=ADVANCED_FACE('',(#{}),{surface},.T.);\n", b + 5, b + 4); + } + t += "#80=CLOSED_SHELL('',(#65,#71,#77));\n#81=MANIFOLD_SOLID_BREP('Piece',#80);\n"; + t += CONTEXT; + t += TAIL; + t +} + +/// The cone inside `z ≤ H` cut by the plane `y = D` (parallel to the axis), keeping `y ≥ D`: +/// the cut meets the cone in the hyperbola `z² − x² = D²`, centre `(0, D, 0)`, both semi axes D. +fn hyperbola_piece() -> String { + const D: f64 = 4.0; + let w = (H * H - D * D).sqrt(); + // Real axis up the plane (X = +z), normal +y, so the imaginary axis is +x: + // (x, y, z) = (D·sinh t, D, D·cosh t). + let curve = "#20=CARTESIAN_POINT('',(0.,4.,0.));\n#21=DIRECTION('',(0.,1.,0.));\n#22=AXIS2_PLACEMENT_3D('',#20,#21,#2);\n#30=HYPERBOLA('',#22,4.,4.);\n#40=DIRECTION('',(0.,-1.,0.));\n#41=AXIS2_PLACEMENT_3D('',#20,#40,#3);\n"; + // Cone: up the hyperbola from +x to −x, back along the arc; the plane (seen from −y): + // the hyperbola from −x to +x, back along the line; the top: the arc, then the line. + cone_piece(curve, [-w, D, H], [w, D, H], false, ["#31- #51-", "#31+ #54-", "#51+ #54+"]) +} + +/// The cone inside `z ≤ H` cut by the plane `z − y = C` (parallel to the generator in the +/// x = 0, y > 0 half plane), keeping `z − y ≥ C`: the cut meets the cone in a parabola with its +/// vertex at `(0, −C/2, C/2)`, axis along (0, 1, 1) and focal distance `C·√2 / 4`. +fn parabola_piece() -> String { + const C: f64 = 8.0; + let w = (H * H - (H - C) * (H - C)).sqrt(); + // Normal (0, 1, −1) out of the solid, axis X = (0, 1, 1), so Y = Z × X = +x: + // (x, y, z) = (4√2·t, −4 + 2t², 4 + 2t²). + let curve = format!( + "#20=CARTESIAN_POINT('',(0.,-4.,4.));\n#21=DIRECTION('',(0.,1.,-1.));\n#23=DIRECTION('',(0.,1.,1.));\n#22=AXIS2_PLACEMENT_3D('',#20,#21,#23);\n#30=PARABOLA('',#22,{:.15});\n#41=AXIS2_PLACEMENT_3D('',#20,#21,#23);\n", + C * 2f64.sqrt() / 4.0 + ); + // Cone (seen from −y): the parabola from −x to +x, back along the arc; the plane (seen from + // outside, +x on the left): the parabola from +x to −x, back along the line; the top: the + // arc round through −y, then the line back. + cone_piece(&curve, [-w, H - C, H], [w, H - C, H], true, ["#31+ #51-", "#31- #54+", "#51+ #54-"]) +} + +/// ∫ from D to H of the circular segment `y ≥ D` of the disc of radius z. +fn hyperbola_piece_volume() -> f64 { + let d = 4.0; + let s = (H * H - d * d).sqrt(); + H.powi(3) / 3.0 * (d / H).acos() - 2.0 / 3.0 * d * H * s + d.powi(3) / 3.0 * ((H + s) / d).ln() +} + +/// ∫ from C/2 to H of the part `y ≤ z − C` of the disc of radius z. +fn parabola_piece_volume() -> f64 { + let c = 8.0; + let q: f64 = 2.0 * c * H - c * c; + H.powi(3) / 3.0 * (PI - (1.0 - c / H).acos()) + - (0.4 * q.powf(2.5) + 4.0 / 3.0 * c * c * q.powf(1.5) + 2.0 * c.powi(4) * q.sqrt()) / (24.0 * c * c) + + (0.4 * q.powf(2.5) - 2.0 / 3.0 * c * c * q.powf(1.5)) / (4.0 * c * c) +} + +fn import_piece(text: &str, want: f64) -> StepImport { + assert_eq!(step_orientation_errors(text).unwrap(), Vec::::new()); + let imp = step_import(text).unwrap(); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert_eq!(imp.bodies.len(), 1); + assert!(imp.bodies[0].closed); + let m = measure(&imp.bodies[0].body).unwrap(); + assert_eq!(m.faces, 3); + assert!(rel(m.volume, want) < 1e-4, "volume {} vs {want}", m.volume); + imp +} + +/// Export the imported piece and read it back: the conic edges are written as exact rational +/// B-splines and come back as the same solid. +fn round_trip(imp: &StepImport, want: f64) { + let text = step_export(&[&imp.bodies[0].body], "conic").unwrap(); + step_validate(&text).unwrap(); + let back = step_import(&text).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert_eq!(back.bodies.len(), 1); + assert!(back.bodies[0].closed); + let v = measure(&back.bodies[0].body).unwrap().volume; + assert!(rel(v, want) < 1e-4, "round trip volume {v} vs {want}"); +} + +#[test] +fn hyperbola_bounded_faces_import() { + let want = hyperbola_piece_volume(); + assert!(rel(want, 175.447_506_9) < 1e-9, "{want}"); + let imp = import_piece(&hyperbola_piece(), want); + round_trip(&imp, want); +} + +#[test] +fn parabola_bounded_faces_import() { + let want = parabola_piece_volume(); + assert!(rel(want, 447.014_684_29) < 1e-9, "{want}"); + let imp = import_piece(¶bola_piece(), want); + round_trip(&imp, want); +} + +/// The kernel curves lie exactly on the conics, end to end, whichever way they run. +#[test] +fn conic_arcs_are_exact() { + let f = Frame { o: mt::Point3::new(1.0, 2.0, 3.0), x: mt::Vector3::unit_y(), y: mt::Vector3::unit_z(), z: mt::Vector3::unit_x() }; + let local = |p: mt::Point3| ((p - f.o).dot(f.x), (p - f.o).dot(f.y), (p - f.o).dot(f.z)); + for (t0, t1) in [(-1.5, 2.0), (3.0, -0.25), (0.0, 7.5)] { + let c = hyperbola_arc(&f, 3.0, 2.0, t0, t1); + let (s0, s1) = c.range_tuple(); + for i in 0..=200 { + let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0)); + assert!((x * x / 9.0 - y * y / 4.0 - 1.0).abs() < 1e-9 && x > 0.0 && z.abs() < 1e-12, "{x} {y}"); + } + let (x, y, _) = local(c.subs(s0)); + assert!((x - 3.0 * f64::cosh(t0)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t0)).abs() < 1e-9); + let (x, y, _) = local(c.subs(s1)); + assert!((x - 3.0 * f64::cosh(t1)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t1)).abs() < 1e-9); + + let c = parabola_arc(&f, 1.5, t0, t1); + let (s0, s1) = c.range_tuple(); + for i in 0..=200 { + let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0)); + assert!((y * y - 4.0 * 1.5 * x).abs() < 1e-9 * (1.0 + x.abs()) && z.abs() < 1e-12, "{x} {y}"); + } + let (x, y, _) = local(c.subs(s1)); + assert!((x - 1.5 * t1 * t1).abs() < 1e-9 && (y - 3.0 * t1).abs() < 1e-9); + } +} + +/// Hostile conics are errors (the face is reported and left out), never panics. +#[test] +fn hostile_conics_never_panic() { + let good = hyperbola_piece(); + for bad in [ + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,0.,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,4.,-1.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,1.E300,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,1.E-300,1.E-300)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,5.,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "PARABOLA('',#22,0.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "PARABOLA('',#22,1.)"), + good.replace("#31=EDGE_CURVE('',#11,#13,#30,.T.)", "#31=EDGE_CURVE('',#11,#11,#30,.T.)"), + ] { + let r = std::panic::catch_unwind(|| step_import(&bad)); + let Ok(r) = r else { panic!("STEP import panicked") }; + if let Ok(imp) = r { + assert!(!imp.warnings.is_empty(), "a broken conic imported silently"); + } + } +} diff --git a/crates/kernel/src/step_in/geom.rs b/crates/kernel/src/step_in/geom.rs index 1925ddd2..ef2d3a28 100644 --- a/crates/kernel/src/step_in/geom.rs +++ b/crates/kernel/src/step_in/geom.rs @@ -1,6 +1,6 @@ //! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces. //! -//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and +//! Points, placements, lines, conics (ellipses, hyperbolas and parabolas), B-splines (plain and rational), elementary surfaces and //! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori. //! Free-form offsets and offsets through collapsed radii remain explicit import warnings. //! Edge curves are trimmed to their vertices here, so every kernel edge runs @@ -54,6 +54,17 @@ pub(crate) enum CurveGeo { a: f64, b: f64, }, + /// Hyperbola branch `o + a·cosh t·x + b·sinh t·y` in the frame's XY plane. + Hyperbola { + f: Frame, + a: f64, + b: f64, + }, + /// Parabola `o + focal·(t²·x + 2t·y)` in the frame's XY plane. + Parabola { + f: Frame, + focal: f64, + }, BSpline(mt::BSplineCurve), Nurbs(mt::NurbsCurve), } @@ -295,6 +306,22 @@ pub(crate) fn curve(cx: &Ctx, id: u64, depth: usize) -> R { } Ok(CurveGeo::Conic { f, a, b }) } + "HYPERBOLA" => { + let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("HYPERBOLA without placement")?)?; + let (a, b) = (length(cx, p.get(2), "semi axis")?, length(cx, p.get(3), "semi imaginary axis")?); + if a <= 0.0 || b <= 0.0 { + return Err("hyperbola axes must be positive".into()); + } + Ok(CurveGeo::Hyperbola { f, a, b }) + } + "PARABOLA" => { + let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("PARABOLA without placement")?)?; + let focal = length(cx, p.get(2), "focal distance")?; + if focal == 0.0 { + return Err("parabola focal distance must not be zero".into()); + } + Ok(CurveGeo::Parabola { f, focal }) + } "B_SPLINE_CURVE_WITH_KNOTS" | "BEZIER_CURVE" | "UNIFORM_CURVE" | "QUASI_UNIFORM_CURVE" => bspline_curve(cx, e), "POLYLINE" => { let pts: Vec = p @@ -346,6 +373,76 @@ pub(crate) fn conic_arc(f: &Frame, a: f64, b: f64, t0: f64, sweep: f64) -> mt::N mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), ctrl)) } +/// Largest hyperbola parameter step one rational quadratic piece spans (keeps its weight +/// `cosh(step / 2)` near 1). +const HYPERBOLA_STEP: f64 = 1.0; + +/// Exact rational quadratic NURBS of the hyperbola branch from parameter `t0` to `t1` (either +/// order): pieces whose middle control point is the meeting point of the end tangents, weighted +/// `cosh` of the half step (the hyperbolic counterpart of [`conic_arc`]). +pub(crate) fn hyperbola_arc(f: &Frame, a: f64, b: f64, t0: f64, t1: f64) -> mt::NurbsCurve { + let n = (((t1 - t0).abs() / HYPERBOLA_STEP).ceil() as usize).clamp(1, 64); + let dt = (t1 - t0) / n as f64; + let w = (dt * 0.5).cosh(); + let at = |c: f64, s: f64| f.at(a * c, b * s, 0.0); + let mut ctrl = Vec::with_capacity(2 * n + 1); + ctrl.push(at(t0.cosh(), t0.sinh()).to_homogeneous()); + let mut knots = vec![0.0, 0.0, 0.0]; + for i in 0..n { + let ta = t0 + dt * i as f64; + let tm = ta + dt * 0.5; + let tb = ta + dt; + let m = at(tm.cosh() / w, tm.sinh() / w); + ctrl.push(mt::Vector4::new(m.x * w, m.y * w, m.z * w, w)); + ctrl.push(at(tb.cosh(), tb.sinh()).to_homogeneous()); + let k = (i + 1) as f64 / n as f64; + knots.push(k); + knots.push(k); + } + knots.push(1.0); + mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), ctrl)) +} + +/// The parabola from parameter `t0` to `t1` (either order): one quadratic Bézier, exact. +pub(crate) fn parabola_arc(f: &Frame, focal: f64, t0: f64, t1: f64) -> mt::BSplineCurve { + let at = |t: f64| f.at(focal * t * t, 2.0 * focal * t, 0.0); + // The end tangents meet above the parameter midpoint: o + focal·(t0·t1, t0 + t1). + let mid = f.at(focal * t0 * t1, focal * (t0 + t1), 0.0); + mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![at(t0), mid, at(t1)]) +} + +/// Parameter of the open conic at `p`, a point on (or within `tol` of) the curve. +fn open_conic_param(g: &CurveGeo, p: mt::Point3, tol: f64) -> R { + let (t, q) = match g { + CurveGeo::Hyperbola { f, a, b } => { + let t = ((p - f.o).dot(f.y) / b).asinh(); + (t, f.at(a * t.cosh(), b * t.sinh(), 0.0)) + } + CurveGeo::Parabola { f, focal } => { + let t = (p - f.o).dot(f.y) / (2.0 * focal); + (t, f.at(focal * t * t, 2.0 * focal * t, 0.0)) + } + _ => return Err("not an open conic".into()), + }; + let d = q.distance(p); + if !t.is_finite() || !(d <= tol) { + return Err(format!( + "edge vertex is {d:.3e} mm off its {} curve", + if matches!(g, CurveGeo::Parabola { .. }) { "parabola" } else { "hyperbola" } + )); + } + Ok(t) +} + +/// The open conic between parameters `t0` and `t1` as a kernel curve. +fn open_conic_curve(g: &CurveGeo, t0: f64, t1: f64) -> R { + match g { + CurveGeo::Hyperbola { f, a, b } => Ok(mt::Curve::NurbsCurve(hyperbola_arc(f, *a, *b, t0, t1))), + CurveGeo::Parabola { f, focal } => Ok(mt::Curve::BSplineCurve(parabola_arc(f, *focal, t0, t1))), + _ => Err("not an open conic".into()), + } +} + fn conic_angle(f: &Frame, a: f64, b: f64, p: mt::Point3) -> f64 { let d = p - f.o; (d.dot(f.y) / b).atan2(d.dot(f.x) / a) @@ -473,6 +570,19 @@ pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: b }) .collect()) } + CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. } => { + if closed { + return Err("closed edge on an open conic".into()); + } + // The curve is open, so the edge is the parameters between its vertices: rising + // for the same sense, falling for the opposite one. + let (ts, te) = (open_conic_param(g, ps, tol)?, open_conic_param(g, pe, tol)?); + let scale = 1.0 + ts.abs().max(te.abs()); + if (te - ts).abs() <= 1e-9 * scale { + return Err("degenerate conic edge".into()); + } + Ok(vec![open_conic_curve(g, ts, te)?]) + } CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()), CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()), } @@ -486,6 +596,21 @@ fn bounded_curve(g: &CurveGeo, extent: &[mt::Point3]) -> R { mt::Curve::Line(mt::Line(*p + *d * lo, *p + *d * hi)) } CurveGeo::Conic { f, a, b } => mt::Curve::NurbsCurve(conic_arc(f, *a, *b, 0.0, TAU)), + CurveGeo::Hyperbola { f, .. } | CurveGeo::Parabola { f, .. } => { + // Bounded by the face's points (their parameters along the curve), with a margin. + let along = |q: &mt::Point3| match g { + CurveGeo::Hyperbola { b, .. } => ((q - f.o).dot(f.y) / b).asinh(), + CurveGeo::Parabola { focal, .. } => (q - f.o).dot(f.y) / (2.0 * focal), + _ => 0.0, + }; + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for t in extent.iter().map(along).filter(|t| t.is_finite()) { + lo = lo.min(t); + hi = hi.max(t); + } + let (lo, hi) = if lo < hi { (lo - (hi - lo) * 0.25 - 1e-3, hi + (hi - lo) * 0.25 + 1e-3) } else { (-1.0, 1.0) }; + open_conic_curve(g, lo, hi)? + } CurveGeo::BSpline(c) => mt::Curve::BSplineCurve(c.clone()), CurveGeo::Nurbs(c) => mt::Curve::NurbsCurve(c.clone()), }) diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 6bf8a91d..0e57150d 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -1268,5 +1268,7 @@ impl StepReading { } } +#[cfg(test)] +mod conic_tests; #[cfg(test)] mod tests; diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index 3cb1c59b..b7c9d378 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -532,7 +532,7 @@ fn analytic_revolution(ex: &p21::Exchange, e: &p21::Entity) -> Option [q.x, q.y, q.z] }) } - CurveGeo::Conic { .. } => return None, + CurveGeo::Conic { .. } | CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. } => return None, }; // A sample point off the axis, its revolution normal (∂S/∂angle × ∂S/∂profile). let sample = @@ -798,7 +798,9 @@ fn edge_points(ex: &p21::Exchange, edge: u64, n: usize) -> Option> { crate::step_in::geom::CurveGeo::Line { .. } => Some(ends.to_vec()), crate::step_in::geom::CurveGeo::BSpline(c) => Some(sample(&|t| c.subs(t), c.range_tuple())), crate::step_in::geom::CurveGeo::Nurbs(c) => Some(sample(&|t| c.subs(t), c.range_tuple())), - crate::step_in::geom::CurveGeo::Conic { .. } => None, + crate::step_in::geom::CurveGeo::Conic { .. } + | crate::step_in::geom::CurveGeo::Hyperbola { .. } + | crate::step_in::geom::CurveGeo::Parabola { .. } => None, } } @@ -862,7 +864,9 @@ fn collapse_short_edges(ex: &mut p21::Exchange) { crate::step_in::geom::CurveGeo::Line { .. } => return Some(true), crate::step_in::geom::CurveGeo::BSpline(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0), crate::step_in::geom::CurveGeo::Nurbs(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0), - crate::step_in::geom::CurveGeo::Conic { .. } => return Some(false), + crate::step_in::geom::CurveGeo::Conic { .. } + | crate::step_in::geom::CurveGeo::Hyperbola { .. } + | crate::step_in::geom::CurveGeo::Parabola { .. } => return Some(false), }; Some(norm(sub([mid.x, mid.y, mid.z], pa)) <= 1e-6 * (1.0 + norm(pa))) }); diff --git a/docs/step-import.md b/docs/step-import.md index 6640a350..18fd0acb 100644 --- a/docs/step-import.md +++ b/docs/step-import.md @@ -34,7 +34,7 @@ The contents of an imported assembly are **rigid by default**: rigid joints (a r Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did. -Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. +Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, hyperbolas and parabolas (as exact rational curves, which export writes back as exact rational B-splines), B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. ## Files from other CAD systems From 221e841d3228f7bd235266d29c00e99f76bffb1a Mon Sep 17 00:00:00 2001 From: "H. Conrad" <78785932+Ian-cmd-ops@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:07:33 -0700 Subject: [PATCH 089/149] Kernel/refine mesh for curved meeting (#1812) Thanks @Ian-cmd-ops. --- crates/kernel/src/ops.rs | 36 ++++++++++++++++++++++++++-- crates/kernel/tests/boolean_props.rs | 4 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 57f266ee..c6dedf91 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -512,6 +512,15 @@ fn meeting_size(a: &Body, b: &Body) -> f64 { if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY } } +/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't). +/// Unlike [`meeting_size`], this follows curved faces: a cylinder's vertices are only the points +/// on its seam, so its vertex box says nothing about where its side reaches. +fn mesh_meeting_size(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> f64 { + let (ba, bb) = (a.bounds(), b.bounds()); + let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size(); + if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY } +} + /// Whether a result volume is plausible for `op`: within the bounds the operands' volumes /// leave, plus the slack the meshed volumes are measured to. fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool { @@ -586,7 +595,7 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) - // refined: a round side trimmed to a thin band (a short boss crossing a gently sloping // floor) is otherwise spanned by triangles cutting through it, which reads as missing // material. - let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); + let mut tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); // A prism pair (a plate and the tool through it) is decided exactly in 2D, with no mesh of // either operand. The sampled shortcuts below need a refined mesh of both, and that mesh is // most of what a boolean costs: a pattern or a multi-profile sketch rebuilds the whole @@ -610,7 +619,30 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) - return Ok(Some(body.clone())); } } - let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); + let mut meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); + // The vertex boxes above miss a curved operand's reach (a cylinder has only seam vertices), + // so a thin meeting region of curved bodies kept the coarse step: a sliver 0.5 mm thick was + // faceted at 0.017 mm and a correct result failed the membership test. The meshes just made + // do span the bodies, so the step is set again from where they meet and the meshes are + // remade once, when that is a much finer step. + let refined = match &meshes { + // A meeting box only a few steps thick is the tessellation error of bodies that merely + // touch or miss each other: a finer mesh there cannot help and only slows the operation. + (Some(ma), Some(mb)) => { + let meet = mesh_meeting_size(ma, mb); + (meet > tol_m * 8.0).then(|| (size * 5e-4).min(meet * 2e-3).max(size * 1e-5)) + } + _ => None, + }; + if let Some(refined) = refined + && refined < tol_m * 0.5 + { + let again = (a.tessellate_with(refined, true).ok(), b.tessellate_with(refined, true).ok()); + if again.0.is_some() && again.1.is_some() { + tol_m = refined; + meshes = again; + } + } let (va, vb) = match &meshes { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume), _ => (volume(a), volume(b)), diff --git a/crates/kernel/tests/boolean_props.rs b/crates/kernel/tests/boolean_props.rs index 397308bf..db634b33 100644 --- a/crates/kernel/tests/boolean_props.rs +++ b/crates/kernel/tests/boolean_props.rs @@ -285,9 +285,9 @@ fn check(r: Result<(), TestCaseError>) { } } -/// A box and a cylinder in general position: the intersect is refused as an implausible volume. +/// A box and a cylinder in general position: the intersect is a sliver 0.5 mm thick. It was +/// refused as an implausible volume, because the meshes of the check were faceted at 0.017 mm. #[test] -#[ignore = "kernel bug, #1647"] fn generic_box_cylinder_intersect() { let a = Prim::Box { at: [11.483767761089219, 4.801612107504224, 3.6313476467611787], size: [4.789088988834864, 3.0, 10.419335961063835] }; let b = Prim::Cyl { at: [29.2480632732879, 8.3683289461485, 6.947922225984966], axis: 2, r: 13.502884497756853, h: 19.719218725477496 }; From daf1dc42017821ca3ab3b00098319b5022e08415 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Sat, 17 Oct 2026 04:07:36 -0400 Subject: [PATCH 090/149] CI: Windows jobs only when what they test changes; only main saves caches (#1840) Thanks @jyviko. --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232c761b..8615b5c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,10 @@ # vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do # pushes to main, the nightly run and Run workflow. # Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are -# skipped on pull requests and run on pushes to main and nightly. +# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders +# and app startup) run on pull requests only when what they test changes (the GPU crate, the app, +# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and +# nightly always run them. Only main saves the build caches; pull requests restore them. # Cross-compilation is opt-in through Run workflow; release builds remain in release.yml. name: CI @@ -57,6 +60,7 @@ jobs: outputs: native: ${{ steps.paths.outputs.native }} kernel: ${{ steps.paths.outputs.kernel }} + windows: ${{ steps.paths.outputs.windows }} full: ${{ steps.paths.outputs.full }} packages: ${{ steps.paths.outputs.packages }} steps: @@ -72,15 +76,23 @@ jobs: run: | native=false kernel=false + windows=false full=true packages= if [ "$EVENT_NAME" != pull_request ]; then native=true + windows=true else # Compare both old and new paths of renames; unknown paths run native checks. git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths" while IFS= read -r -d '' path; do case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac + # What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and + # its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts. + case "$path" in + crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;; + tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;; + esac case "$path" in .github/workflows/ci.yml) native=true ;; *.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;; @@ -88,6 +100,12 @@ jobs: *) native=true ;; esac done < "$RUNNER_TEMP/ci-paths" + # A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows). + if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \ + && git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \ + | grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then + windows=true + fi # Pull requests build and test the touched packages and their dependents. python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected" cat "$RUNNER_TEMP/ci-affected" @@ -99,6 +117,7 @@ jobs: { echo "native=$native" echo "kernel=$kernel" + echo "windows=$windows" echo "full=$full" echo "packages=$packages" } >> "$GITHUB_OUTPUT" @@ -115,6 +134,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: repo-checks + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Format run: cargo fmt --all -- --check - name: Asset attribution @@ -146,6 +166,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: native-clippy + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Clippy env: FULL: ${{ needs.changes.outputs.full }} @@ -173,6 +194,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: native-tests + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -371,6 +393,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: doc-tests + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -395,6 +418,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: oracle + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -418,7 +442,7 @@ jobs: windows-fxc: name: Windows D3D12 FXC shaders needs: changes - if: needs.changes.outputs.native == 'true' + if: needs.changes.outputs.windows == 'true' runs-on: windows-latest timeout-minutes: 45 env: @@ -433,6 +457,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-fxc + save-if: ${{ github.ref == 'refs/heads/main' }} # One test at a time: WARP's shader JIT crashes now and then (a null read in # d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute # pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not @@ -445,7 +470,7 @@ jobs: windows-startup: name: Windows app startup needs: changes - if: needs.changes.outputs.native == 'true' + if: needs.changes.outputs.windows == 'true' runs-on: windows-latest timeout-minutes: 60 steps: @@ -454,6 +479,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-startup + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Backend order and probing on Windows run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests - name: Build the app @@ -476,6 +502,7 @@ jobs: CHANGES: ${{ needs.changes.result }} CHECKS: ${{ needs.checks.result }} NATIVE: ${{ needs.changes.outputs.native }} + WINDOWS: ${{ needs.changes.outputs.windows }} CLIPPY: ${{ needs.clippy.result }} BUILD: ${{ needs.build-tests.result }} TESTS: ${{ needs.tests.result }} @@ -492,6 +519,8 @@ jobs: test "$TESTS" = success test "$DOCS" = success test "$ORACLE" = success + fi + if [ "$WINDOWS" = true ]; then test "$WINDOWS_FXC" = success test "$WINDOWS_STARTUP" = success fi @@ -509,6 +538,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: wasm + save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo xtask wasm riscv64-gates: @@ -530,6 +560,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: riscv64 + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install cross-compilation dependencies run: | # Ubuntu's regular archive serves amd64; RISC-V packages live in ports. From 0292fa35e6378e43aa05ad7433a233d553f758b0 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:40 +0200 Subject: [PATCH 091/149] Sketch Fillet: pick the two lines of a corner where more lines meet (#1824) (#1826) Thanks @SychPL. --- crates/ui-egui/src/sketch_corner_ui_tests.rs | 44 ++++++++++++++++++++ crates/ui-egui/src/sketch_modify_ui.rs | 41 ++++++++++++++---- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index c07e49d2..d5359c9b 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -431,3 +431,47 @@ fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() { assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. }))); assert_eq!(ss.profiles.len(), 1); } + +/// #1824: where a third line ends at a corner's point, the point names no single corner; the +/// Fillet window takes the corner's two lines instead and rounds the corner between them. +#[test] +fn fillet_window_rounds_the_corner_between_two_picked_lines() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]],"ids":["left"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[20,0]],"ids":["right"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[18,8]],"ids":["diag"]})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start":"sketch.fillet"})).unwrap(); + // The shared point ends three lines, so it names no single corner: pick the two lines. + h.step(&json!({"click":{"world":[16,0,0]}})).unwrap(); + h.step(&json!({"click":{"world":[13,3,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec!["right".to_string(), "diag".to_string()], "{:?}", h.app.status); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.status); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).count(), 1, "{:?}", h.app.status); +} + +/// #1824 (review): the Chamfer window takes a corner's two lines the same way. +#[test] +fn chamfer_window_cuts_the_corner_between_two_picked_lines() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]],"ids":["left"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[20,0]],"ids":["right"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[18,8]],"ids":["diag"]})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + let before = h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(); + h.step(&json!({"start":"sketch.chamfer.equal_distance"})).unwrap(); + h.step(&json!({"click":{"world":[16,0,0]}})).unwrap(); + h.step(&json!({"click":{"world":[13,3,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec!["right".to_string(), "diag".to_string()], "{:?}", h.app.status); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.status); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), before + 1, "a chamfer line: {:?}", h.app.status); + assert!(sk.curve_index("left").is_some(), "the third line stays"); +} diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 53f5e973..2f6cf12c 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -150,9 +150,10 @@ fn inputs(k: &Mod) -> Vec { // The centre is required: `sketch.pattern.circular` has no default for it. Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)], // A fillet rounds any number of corners to one radius (`points`). Each chamfer command - // cuts one corner, so its input holds one. - Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS, true)], - Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS, false)], + // cuts one corner: its point, or its two lines. Two lines name the corner between them + // where more lines meet at its point (#1824), for both. + Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)], + Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)], } } @@ -548,6 +549,17 @@ fn length(app: &SolveApp, label: &str, text: &str) -> Result { } Ok(v) } +/// The curve ids picked in input `i` (a corner's two lines, #1824). +fn line_ids(inputs: &[SelInput], i: usize) -> Vec { + items(inputs, i) + .iter() + .filter_map(|x| match x { + Sel::SketchCurve { id } => Some(id.clone()), + _ => None, + }) + .collect() +} + /// Corner point ids within the active sketch, including qualified script selections. fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec { items(inputs, i).iter().filter_map(|sel| point_id(&app.session, sel).map(str::to_string)).collect() @@ -734,15 +746,26 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { let corners = corner_ids(app, inputs, 0); - if corners.is_empty() { - return Err("select the corners to round first".into()); - } + let lines = line_ids(inputs, 0); // The radius is checked where it is evaluated, against the document's parameters. - ("sketch.fillet", json!({"points": corners, "radius": size.trim()})) + match (corners.is_empty(), lines.as_slice()) { + (true, []) => return Err("select the corners to round first".into()), + (false, []) => ("sketch.fillet", json!({"points": corners, "radius": size.trim()})), + // Two lines name their corner even where other lines end at its point (#1824). + (true, [a, b]) => ("sketch.fillet", json!({"a": a, "b": b, "radius": size.trim()})), + _ => return Err("select corner points, or the two lines of one corner".into()), + } } Mod::Corner { ctype, size, distance2, angle, flip, .. } => { - let point = corner_ids(app, inputs, 0).into_iter().next().ok_or("select the corner to cut first")?; - let mut p = corner_ref(app, &point, *flip && *ctype > 0)?; + let flipped = *flip && *ctype > 0; + let mut p = match (corner_ids(app, inputs, 0).as_slice(), line_ids(inputs, 0).as_slice()) { + ([point], []) => corner_ref(app, point, flipped)?, + // Flip swaps the lines, so `distance` applies to the other leg. + ([], [a, b]) if flipped => json!({"a": b, "b": a}), + ([], [a, b]) => json!({"a": a, "b": b}), + ([], []) => return Err("select the corner to cut first".into()), + _ => return Err("select one corner point, or the two lines of one corner".into()), + }; p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?); match ctype { 1 => { From 7ba2d5db471d8e530d54b550eec0aa6d349df3af Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Sat, 17 Oct 2026 04:07:43 -0400 Subject: [PATCH 092/149] =?UTF-8?q?Robustness=20goldens:=20one=20test=20pe?= =?UTF-8?q?r=20seed,=20so=20CI=20spreads=20them=20(full=20runs=20~33=20?= =?UTF-8?q?=E2=86=92=20~15-20=20min)=20(#1851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks @jyviko. --- .config/nextest.toml | 2 +- .github/workflows/ci.yml | 8 +- CONTRIBUTING.md | 2 +- crates/engine/src/tests/robustness_goldens.rs | 166 +++++++++++++----- docs/book/src/dev/testing.md | 2 +- docs/robustness.md | 6 +- 6 files changed, 134 insertions(+), 52 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 66fe1043..92451dd5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,7 +11,7 @@ not ( (package(solidcraft-engine) & ( test(=tests::hostile_params_never_panic) | test(/^tests::robustness::/) - | test(/^tests::robustness_goldens::/) + | test(/^tests::robustness_goldens::seed_/) | test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen) | test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs) | test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8615b5c2..e4674f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,15 +267,15 @@ jobs: cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ --profile ci --partition "slice:$SHARD/4" --no-tests=pass "${all[@]}" - name: Robustness goldens sample (pull requests that touch the kernel) - # Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and - # the nightly run every seed in the step above. + # Every 8th seed of tests/robustness/goldens.json (each seed is a test), a quarter per + # shard; pushes to main and the nightly run every seed in the step above. if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true' env: SHARD: ${{ matrix.shard }} run: | cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ - --profile ci --ignore-default-filter \ - -E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))" + --profile ci --ignore-default-filter --partition "slice:$SHARD/4" --no-tests=pass \ + -E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::seed_(00|08|16|24|32|40|48|56|64|72|80|88)$/)' - name: Upload test timings if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1232a448..5bbeeb20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,7 +180,7 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev `.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md): - `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and - `tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part), + the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th), `pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`, `pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`, `cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests diff --git a/crates/engine/src/tests/robustness_goldens.rs b/crates/engine/src/tests/robustness_goldens.rs index 9cee7ef0..05922229 100644 --- a/crates/engine/src/tests/robustness_goldens.rs +++ b/crates/engine/src/tests/robustness_goldens.rs @@ -5,12 +5,14 @@ //! volume and area (within a relative 1e-6) and their face, edge and shell counts (exact). Any //! change fails, the better and the worse alike: a seed that used to be refused and now builds //! is recorded on purpose, with the reason. To record: `ROBUSTNESS_GOLDENS_BLESS="why" cargo -//! test --profile ci -p solidcraft-engine --lib robustness_goldens:: -- --include-ignored` -//! (docs/robustness.md). The reason goes into the file's `notes` with the seeds it changed. +//! test --profile ci -p solidcraft-engine --lib robustness_goldens::` (docs/robustness.md); run +//! it with `cargo test`, whose tests share one process and so one lock on the file, not with +//! nextest. The reason goes into the file's `notes` with the seeds it changed. //! -//! Pull requests that touch `crates/kernel` run a sample (`sample_0` … `sample_3`, every 8th -//! seed, split so the shards share them); pushes to main and the nightly run every seed -//! (`all_0` … `all_3`). +//! Each seed is its own test (`seed_00` … `seed_95`), so the runner spreads them over the CI +//! shards and their cores: a few seeds take minutes, and grouped four ways they made one test +//! of half an hour. Pull requests that touch `crates/kernel` run every 8th seed (ci.yml); +//! pushes to main and the nightly run every seed. use std::collections::BTreeMap; use std::path::PathBuf; @@ -52,9 +54,8 @@ fn differs(want: &Value, got: &Value) -> Option { (!out.is_empty()).then(|| out.join("; ")) } -fn check(part: u64, parts: u64, every: u64) { - let seeds: Vec = (0..SEEDS).filter(|s| s % every == 0 && (s / every) % parts == part).collect(); - let got: BTreeMap = seeds.iter().map(|s| (*s, fuzz::golden(*s, MAX_STEPS))).collect(); +fn check(seed: u64) { + let got: BTreeMap = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]); let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner()); let mut file = load(); let changed: Vec<(u64, String)> = got @@ -111,42 +112,123 @@ fn check(part: u64, parts: u64, every: u64) { ); } -#[test] -fn sample_0() { - check(0, 4, 8); +/// One test per seed, and the list of them. +macro_rules! seeds { + ($($name:ident = $seed:literal),* $(,)?) => { + $( + #[test] + fn $name() { + check($seed); + } + )* + const LISTED: &[u64] = &[$($seed),*]; + }; } -#[test] -fn sample_1() { - check(1, 4, 8); -} +seeds!( + seed_00 = 0, + seed_01 = 1, + seed_02 = 2, + seed_03 = 3, + seed_04 = 4, + seed_05 = 5, + seed_06 = 6, + seed_07 = 7, + seed_08 = 8, + seed_09 = 9, + seed_10 = 10, + seed_11 = 11, + seed_12 = 12, + seed_13 = 13, + seed_14 = 14, + seed_15 = 15, + seed_16 = 16, + seed_17 = 17, + seed_18 = 18, + seed_19 = 19, + seed_20 = 20, + seed_21 = 21, + seed_22 = 22, + seed_23 = 23, + seed_24 = 24, + seed_25 = 25, + seed_26 = 26, + seed_27 = 27, + seed_28 = 28, + seed_29 = 29, + seed_30 = 30, + seed_31 = 31, + seed_32 = 32, + seed_33 = 33, + seed_34 = 34, + seed_35 = 35, + seed_36 = 36, + seed_37 = 37, + seed_38 = 38, + seed_39 = 39, + seed_40 = 40, + seed_41 = 41, + seed_42 = 42, + seed_43 = 43, + seed_44 = 44, + seed_45 = 45, + seed_46 = 46, + seed_47 = 47, + seed_48 = 48, + seed_49 = 49, + seed_50 = 50, + seed_51 = 51, + seed_52 = 52, + seed_53 = 53, + seed_54 = 54, + seed_55 = 55, + seed_56 = 56, + seed_57 = 57, + seed_58 = 58, + seed_59 = 59, + seed_60 = 60, + seed_61 = 61, + seed_62 = 62, + seed_63 = 63, + seed_64 = 64, + seed_65 = 65, + seed_66 = 66, + seed_67 = 67, + seed_68 = 68, + seed_69 = 69, + seed_70 = 70, + seed_71 = 71, + seed_72 = 72, + seed_73 = 73, + seed_74 = 74, + seed_75 = 75, + seed_76 = 76, + seed_77 = 77, + seed_78 = 78, + seed_79 = 79, + seed_80 = 80, + seed_81 = 81, + seed_82 = 82, + seed_83 = 83, + seed_84 = 84, + seed_85 = 85, + seed_86 = 86, + seed_87 = 87, + seed_88 = 88, + seed_89 = 89, + seed_90 = 90, + seed_91 = 91, + seed_92 = 92, + seed_93 = 93, + seed_94 = 94, + seed_95 = 95, +); +/// Every seed in 0..SEEDS has its test, once, and every recorded seed is one of them. #[test] -fn sample_2() { - check(2, 4, 8); -} - -#[test] -fn sample_3() { - check(3, 4, 8); -} - -#[test] -fn all_0() { - check(0, 4, 1); -} - -#[test] -fn all_1() { - check(1, 4, 1); -} - -#[test] -fn all_2() { - check(2, 4, 1); -} - -#[test] -fn all_3() { - check(3, 4, 1); +fn every_seed_has_a_test() { + assert_eq!(LISTED, (0..SEEDS).collect::>().as_slice()); + let file = load(); + let recorded: Vec = file["seeds"].as_object().into_iter().flatten().filter_map(|(k, _)| k.parse().ok()).collect(); + assert!(recorded.iter().all(|s| *s < SEEDS), "{recorded:?}"); } diff --git a/docs/book/src/dev/testing.md b/docs/book/src/dev/testing.md index e275dc42..219b7328 100644 --- a/docs/book/src/dev/testing.md +++ b/docs/book/src/dev/testing.md @@ -77,7 +77,7 @@ Pull requests skip a few heavy tests (each takes one to four minutes); they run `main`, nightly, and in `cargo xtask test`: - `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and - `tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part), + the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th), `pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`, `pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`, `cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests diff --git a/docs/robustness.md b/docs/robustness.md index 6c350610..db5f866d 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -43,13 +43,13 @@ its steps, how it ended (ok, or the kind and the start of the error it was refus for the design it left the bodies' total volume and area (within a relative 1e-6) and their face, edge and shell counts (exact). `tests::robustness_goldens` fails on any change, better or worse, so a kernel change shows every seed it moves. Pull requests that touch `crates/kernel` -run every 8th seed (`sample_0` … `sample_3`, about two minutes); pushes to `main` and the -nightly run them all (`all_0` … `all_3`). +run every 8th seed; pushes to `main` and the nightly run them all. Each seed is its own test +(`seed_00` … `seed_95`), so the CI shards and their cores share them out. To record an intended change, give the reason: ``` -ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::all +ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::seed_ ``` The changed seeds and the reason are added to the file's `notes`; say why in the pull request too. From 98ce6a4ee88ae7642c6ba271820c150e624ff62e Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Sat, 17 Oct 2026 04:07:46 -0400 Subject: [PATCH 093/149] Sweep guide rails and two-rail sweep (#771) (#1806) Thanks @jyviko. --- ROADMAP.md | 5 +- assets/locales/de.json | 4 + assets/locales/en.json | 4 + assets/locales/fr.json | 4 + assets/locales/ja.json | 4 + assets/locales/zh.json | 4 + crates/doc/src/document.rs | 37 ++++ crates/doc/src/eval.rs | 75 ++++++-- crates/doc/src/eval_surface.rs | 20 ++- .../src/format/pending/sweep-guide-rails.rs | 5 + crates/doc/src/walk.rs | 1 + crates/engine/src/cmd/features.rs | 50 +++++- crates/engine/src/cmd/surface.rs | 16 +- crates/engine/src/cmd/sweep_rails_tests.rs | 143 +++++++++++++++ crates/kernel/src/build.rs | 2 +- crates/kernel/src/lib.rs | 8 +- crates/kernel/src/sweep_path.rs | 165 ++++++++++++++++- crates/kernel/src/sweep_rails_tests.rs | 166 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 23 ++- crates/ui-egui/src/dialogs_surface.rs | 24 ++- crates/ui-egui/src/dialogs_sweep.rs | 60 ++++++- crates/ui-egui/src/sweep_dialog_tests.rs | 39 ++++ 22 files changed, 804 insertions(+), 55 deletions(-) create mode 100644 crates/doc/src/format/pending/sweep-guide-rails.rs create mode 100644 crates/engine/src/cmd/sweep_rails_tests.rs create mode 100644 crates/kernel/src/sweep_rails_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 007ddbd1..eb0ecb57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | deferred | — | +| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | | M11 | 2D drawings | deferred | — | @@ -383,6 +383,9 @@ completed drawings publish atomically. The browser still exports synchronously. A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled walls through denser stations where the path has corners or the profile has holes. + Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail + turns the profile and scales it (or stretches it across to the rail) against the start; two + rails carry it between them. Rails along a path with sharp corners are refused for now. - Extend intersects exact ellipses and rational/high-degree splines without moving the line or circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance certificate and refine within bounded node limits while retaining existing IDs. diff --git a/assets/locales/de.json b/assets/locales/de.json index 464f585f..9394a574 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1077,6 +1077,8 @@ "ui.quit": "Beenden", "ui.radius": "Radius", "ui.radius_type": "Radius-Typ", + "ui.rail": "Leitkurve", + "ui.rail_fit": "Anpassung an Leitkurve", "ui.reachable": "Erreichbar", "ui.rectangle": "Rechteck", "ui.rectangular_pattern": "Rechteckmuster", @@ -1100,6 +1102,7 @@ "ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}", "ui.saved_to": "Gespeichert: {path}", "ui.scale": "Skalieren", + "ui.second_rail": "Zweite Leitkurve", "ui.section_analysis": "Querschnittsanalyse", "ui.select": "Auswählen", "ui.selectOther": "Andere auswählen…", @@ -1144,6 +1147,7 @@ "ui.split_body": "Körper trennen", "ui.start": "Start", "ui.start_radius": "Startradius", + "ui.stretch": "Strecken", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Nur bei ruhender Ansicht", "ui.supersampling_off": "Aus (1×)", diff --git a/assets/locales/en.json b/assets/locales/en.json index 80aa3fe9..d23e3cb7 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1330,6 +1330,8 @@ "ui.radial_context_menu": "Radial context menu", "ui.radius": "Radius", "ui.radius_type": "Radius Type", + "ui.rail": "Rail", + "ui.rail_fit": "Rail Fit", "ui.reachable": "Reachable", "ui.rectangle": "Rectangle", "ui.rectangular_pattern": "Rectangular Pattern", @@ -1356,6 +1358,7 @@ "ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}", "ui.saved_to": "Saved {path}", "ui.scale": "Scale", + "ui.second_rail": "Second Rail", "ui.section_analysis": "Section Analysis", "ui.select": "Select", "ui.selectOther": "Select Other…", @@ -1406,6 +1409,7 @@ "ui.stand_up_tip": "After a STEP or IGES file opens with Z up, offer a quarter turn about X", "ui.start": "Start", "ui.start_radius": "Start Radius", + "ui.stretch": "Stretch", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Only when the view is still", "ui.supersampling_off": "Off (1×)", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 7046e221..d6b490c3 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1138,6 +1138,8 @@ "ui.radial_context_menu": "Menu contextuel radial", "ui.radius": "Rayon", "ui.radius_type": "Type de rayon", + "ui.rail": "Rail", + "ui.rail_fit": "Ajustement au rail", "ui.reachable": "Accessible", "ui.rectangle": "Rectangle", "ui.rectangular_pattern": "Réseau rectangulaire", @@ -1164,6 +1166,7 @@ "ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}", "ui.saved_to": "Enregistré : {path}", "ui.scale": "Échelle", + "ui.second_rail": "Second rail", "ui.section_analysis": "Analyse de coupe", "ui.select": "Sélectionner", "ui.selectOther": "Sélectionner un autre…", @@ -1214,6 +1217,7 @@ "ui.stand_up_tip": "Après l'ouverture d'un fichier STEP ou IGES avec Z vers le haut, proposer un quart de tour autour de X", "ui.start": "Début", "ui.start_radius": "Rayon initial", + "ui.stretch": "Étirer", "ui.supersampling": "Suréchantillonnage (SSAA)", "ui.supersampling_adaptive": "Seulement quand la vue est immobile", "ui.supersampling_off": "Désactivé (1×)", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 48fe560c..f12aebd1 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1330,6 +1330,8 @@ "ui.radial_context_menu": "パイ メニュー", "ui.radius": "半径", "ui.radius_type": "半径タイプ", + "ui.rail": "レール", + "ui.rail_fit": "レールの合わせ方", "ui.reachable": "到達可能", "ui.rectangle": "長方形", "ui.rectangular_pattern": "矩形パターン", @@ -1356,6 +1358,7 @@ "ui.save_mesh_temp_failed": "{folder} を作成できません: {error}", "ui.saved_to": "保存しました: {path}", "ui.scale": "尺度", + "ui.second_rail": "第2レール", "ui.section_analysis": "断面解析", "ui.select": "選択", "ui.selectOther": "その他を選択…", @@ -1406,6 +1409,7 @@ "ui.stand_up_tip": "Z軸上向きで開いたSTEPまたはIGESファイルに対し、X軸周りに90度回転させるか確認します", "ui.start": "開始", "ui.start_radius": "開始半径", + "ui.stretch": "伸縮", "ui.supersampling": "スーパーサンプリング (SSAA)", "ui.supersampling_adaptive": "ビューが静止しているときのみ", "ui.supersampling_off": "オフ (1×)", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 9d2600b5..2c6899a0 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1330,6 +1330,8 @@ "ui.radial_context_menu": "环形上下文菜单", "ui.radius": "半径", "ui.radius_type": "半径类型", + "ui.rail": "导轨", + "ui.rail_fit": "导轨适配方式", "ui.reachable": "可到达", "ui.rectangle": "矩形", "ui.rectangular_pattern": "矩形阵列", @@ -1356,6 +1358,7 @@ "ui.save_mesh_temp_failed": "无法创建 {folder}:{error}", "ui.saved_to": "已保存:{path}", "ui.scale": "缩放", + "ui.second_rail": "第二导轨", "ui.section_analysis": "截面分析", "ui.select": "选择", "ui.selectOther": "选择其他…", @@ -1406,6 +1409,7 @@ "ui.stand_up_tip": "当 Z 朝上的 STEP 或 IGES 文件打开后,提示绕 X 轴旋转四分之一周", "ui.start": "开始", "ui.start_radius": "起始半径", + "ui.stretch": "伸展", "ui.supersampling": "超级采样 (SSAA)", "ui.supersampling_adaptive": "仅在视图静止时", "ui.supersampling_off": "关 (1×)", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 00523f8e..7e43f219 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -299,6 +299,31 @@ impl SweepOrientation { } } +/// A sweep's guide rail: a chain of curves of a sketch, followed from the end nearest the path's +/// start. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +pub struct SweepRail { + pub sketch: SketchId, + pub curves: Vec, +} + +/// How a sweep's guide rails reshape its profile. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RailFit { + /// Evenly, with the distance across to the rail (between the rails). + #[default] + Scale, + /// Only across to the rail (between the rails). + Stretch, +} + +impl RailFit { + pub fn is_scale(&self) -> bool { + *self == RailFit::Scale + } +} + /// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses; /// an open chain's is the side it bends toward (a straight one's, the left of its first /// curve as drawn). @@ -729,6 +754,12 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(unitless = "Scale")] scale: Option, + /// Guide rails (one or two) the profile follows, instead of orientation, twist and scale. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + rails: Vec, + /// How the rails reshape the profile. + #[serde(default, skip_serializing_if = "RailFit::is_scale")] + rail_fit: RailFit, #[serde(default)] operation: Operation, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1316,6 +1347,12 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(unitless = "Scale")] scale: Option, + /// Guide rails (one or two) the curves follow, instead of orientation, twist and scale. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + rails: Vec, + /// How the rails reshape the curves. + #[serde(default, skip_serializing_if = "RailFit::is_scale")] + rail_fit: RailFit, }, /// A surface body lofted through sections in order, each one open or closed chain of /// sketch curves: walls only, no caps. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 0b5ddf0c..db6bce95 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -962,17 +962,25 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), _ => None, }; + let wired = |id: &crate::SketchId| doc.sketch(id.0).is_ok_and(|sketch| !sketch.wires.is_empty()); + let rails: &[crate::SweepRail] = match &f.kind { + FeatureKind::Sweep { rails, .. } | FeatureKind::SurfaceSweep { rails, .. } => rails, + _ => &[], + }; if let Some(path) = path - && doc.sketch(path.0).is_ok_and(|sketch| !sketch.wires.is_empty()) + && (wired(&path) || rails.iter().any(|r| wired(&r.sketch))) { let mut hash = std::collections::hash_map::DefaultHasher::new(); let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0); for frame in [doc.component_transform(f.component), doc.component_transform(owner)] { frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); } - if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind { - let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0); - doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + if let FeatureKind::Sweep { sketch, rails, .. } | FeatureKind::SurfaceSweep { sketch, rails, .. } = &f.kind { + // The profile's and the rails' components place them too. + for sketch in std::iter::once(sketch).chain(rails.iter().map(|r| &r.sketch)) { + let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0); + doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } } return hash.finish(); } @@ -3271,8 +3279,7 @@ fn feature_tools_with_constructor( let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() }; Ok(vec![roles.tool(body)]) } - FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => { - let ctl = sweep_control(vals, orientation, twist, scale)?; + FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, rails, rail_fit, .. } => { let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -3288,11 +3295,25 @@ fn feature_tools_with_constructor( let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?); let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default(); let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None); + let points = path_points(ps, path, start)?; + let segs = match points { + Some(_) => Vec::new(), + None => path_segments(ps, path, start)?, + }; + let path_start = points + .as_ref() + .and_then(|p| p.first().copied()) + .or_else(|| match segs.first() { + Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => Some(*a), + None => None, + }) + .unwrap_or(start); + let rails = rail_polylines(doc, st, rails, f.component, path_start)?; + let ctl = sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; // A path through 3D sketch curves follows them as points. - if let Some(pts) = path_points(ps, path, start)? { + if let Some(pts) = points { return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect(); } - let segs = path_segments(ps, path, start)?; regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect() } FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { @@ -3341,13 +3362,16 @@ fn feature_tools_with_constructor( } /// A chain of sketch curves as 3D path segments, starting at the end nearest `start`. -/// A sweep's options from its stored orientation and its twist and end scale expressions -/// (none: no twist, scale 1). Out-of-range values are refused by the kernel. +/// A sweep's options from its stored orientation, its twist and end scale expressions (none: no +/// twist, scale 1) and its guide rails as polylines (see [`rail_polylines`]). Out-of-range values +/// are refused by the kernel. pub(crate) fn sweep_control( vals: &BTreeMap, orientation: &crate::SweepOrientation, twist: &Option, scale: &Option, + rails: Vec>, + rail_fit: crate::RailFit, ) -> Result { let orientation = match *orientation { crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular, @@ -3362,11 +3386,40 @@ pub(crate) fn sweep_control( Some(k) => val(vals, k, Kind::Unitless)?, None => 1.0, }; - let ctl = kernel::SweepControl { orientation, twist, scale }; + let rail_fit = match rail_fit { + crate::RailFit::Scale => kernel::RailFit::Scale, + crate::RailFit::Stretch => kernel::RailFit::Stretch, + }; + let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit }; ctl.check()?; Ok(ctl) } +/// A sweep's guide rails as polylines in the feature's frame, each a chain of its sketch's curves +/// (planar or 3D) from the end nearest `start`, the start of the path. +pub(crate) fn rail_polylines(doc: &Document, st: &ModelState, rails: &[crate::SweepRail], consumer: u64, start: Vec3) -> Result>> { + if rails.len() > 2 { + return Err(DocError::Invalid("a sweep takes one or two guide rails".into())); + } + rails + .iter() + .map(|rail| { + if rail.curves.is_empty() { + return Err(DocError::Invalid("a guide rail needs its curves".into())); + } + let rs = st + .sketch(rail.sketch.0) + .ok_or_else(|| DocError::Unknown(format!("rail sketch {} (it must come earlier in the timeline)", rail.sketch)))?; + let mapped = path_in_frame(doc, rs, consumer)?; + let rs = mapped.as_ref(); + match path_points(rs, &rail.curves, start)? { + Some(pts) => Ok(pts), + None => Ok(kernel::path_polyline(&path_segments(rs, &rail.curves, start)?)?), + } + }) + .collect() +} + /// A pipe's section: a circle of radius `r`, hollow when it has a wall. fn pipe_region(vals: &BTreeMap, r: f64, wall: &Option) -> Result { let holes = match wall { diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 80917515..1464ecda 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -286,8 +286,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => { - let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?; + FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale, rails, rail_fit } => { let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -306,8 +305,21 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, let pts: Vec = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect(); let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64)); let body = match path_points(ps, path, start)? { - Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?, - None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?, + Some(pts) => { + let rails = crate::eval::rail_polylines(doc, st, rails, f.component, pts.first().copied().unwrap_or(start))?; + let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; + kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)? + } + None => { + let segs = path_segments(ps, path, start)?; + let path_start = match segs.first() { + Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => *a, + None => start, + }; + let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?; + let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; + kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)? + } }; let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body); push_sheet(st, f, traced); diff --git a/crates/doc/src/format/pending/sweep-guide-rails.rs b/crates/doc/src/format/pending/sweep-guide-rails.rs new file mode 100644 index 00000000..a5c37dec --- /dev/null +++ b/crates/doc/src/format/pending/sweep-guide-rails.rs @@ -0,0 +1,5 @@ +//! A `Sweep` and a `SurfaceSweep` can carry guide `rails` (one or two, each a `sketch` and its +//! `curves` in order) and `rail_fit` (`scale`, the default, or `stretch`): the profile follows +//! the rails instead of an orientation, twist and end scale (#771). A sweep without them saves as +//! it always did, so older designs need no transformation; 0.13.x asks for an update instead of +//! sweeping a railed profile without its rails. diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 66135aaf..fa09250a 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -53,6 +53,7 @@ impl Walk for crate::document::FilletFit {} impl Walk for crate::document::FilletCorner {} impl Walk for crate::document::MeshData {} impl Walk for crate::document::RevolveConvention {} +impl Walk for crate::document::RailFit {} impl Walk for crate::document::CoilSection {} impl Walk for crate::document::ThinSide {} impl Walk for crate::document::ExtendMode {} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 15763a5d..c614a0f9 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -2,7 +2,8 @@ use serde_json::{Value, json}; use solidcraft_doc::{ - AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr, + AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall, + expr::Kind, point_expr, }; use solidcraft_doc::{BodyName, FeatureName, SketchId}; use solidcraft_doc::{Dir, FacePick, Position}; @@ -25,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") - .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"), + .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"), @@ -1850,6 +1851,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`path` must list curve ids")); } let (orientation, twist, scale) = sweep_options(s, p, cmd)?; + let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?; add_feature( s, p, @@ -1861,6 +1863,8 @@ fn sweep(s: &mut Session, p: &Value) -> Result { orientation, twist, scale, + rails, + rail_fit, operation: operation(p, cmd)?, targets: string_list(p, "targets").into_iter().map(BodyName).collect(), }, @@ -1910,6 +1914,44 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO Ok((orientation, twist, scale)) } +/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two +/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or +/// stretch; and no orientation, twist or scale besides, since the rails set those. +pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -> Result<(Vec, RailFit)> { + let rail_fit = match p.get("rail_fit") { + None | Some(Value::Null) => RailFit::Scale, + Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale, + Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch, + Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")), + }; + let given = match p.get("rails") { + None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, "`rails` must list one or two rails: [{sketch?, curves: [ids]}]")), + }; + if !(1..=2).contains(&given.len()) { + return Err(bad(cmd, "a sweep takes one or two guide rails")); + } + for key in ["orientation", "twist", "scale"] { + if p.get(key).is_some_and(|v| !v.is_null()) { + return Err(bad(cmd, format!("guide rails set the profile's turn and size along the path: leave out `{key}`"))); + } + } + let mut rails = Vec::with_capacity(given.len()); + for rail in given { + let sketch = match rail.get("sketch") { + None | Some(Value::Null) => path_sketch, + Some(_) => sketch_id(s, rail.get("sketch"), cmd, "rails[].sketch")?, + }; + let curves = string_list(rail, "curves"); + if curves.is_empty() || curves.len() > 1000 { + return Err(bad(cmd, "each rail's `curves` must list 1…1000 curve ids in order")); + } + rails.push(SweepRail { sketch: SketchId(sketch), curves }); + } + Ok((rails, rail_fit)) +} + fn loft(s: &mut Session, p: &Value) -> Result { let cmd = "solid.loft"; let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?; @@ -2059,6 +2101,10 @@ mod revolve_convention_tests; #[path = "sweep_options_tests.rs"] mod sweep_options_tests; +#[cfg(test)] +#[path = "sweep_rails_tests.rs"] +mod sweep_rails_tests; + #[cfg(test)] #[path = "offset_topology_tests.rs"] mod offset_topology_tests; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 56c45a77..ea161f8e 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -47,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep) .at("SURFACE", "CREATE") .icon("surface_sweep") - .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale? (as solid.sweep); name?, body_name? (a surface body, no caps)"), + .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"), CommandSpec::new("surface.loft", "Surface Loft", surface_loft) .at("SURFACE", "CREATE") .icon("surface_loft") @@ -354,10 +354,22 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`path` must list 1…1000 curve ids")); } let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?; + let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_sketch)?; add_feature( s, p, - FeatureKind::SurfaceSweep { sketch: SketchId(sketch), profiles, curves, path_sketch: SketchId(path_sketch), path, orientation, twist, scale }, + FeatureKind::SurfaceSweep { + sketch: SketchId(sketch), + profiles, + curves, + path_sketch: SketchId(path_sketch), + path, + orientation, + twist, + scale, + rails, + rail_fit, + }, ) } diff --git a/crates/engine/src/cmd/sweep_rails_tests.rs b/crates/engine/src/cmd/sweep_rails_tests.rs new file mode 100644 index 00000000..badeffc6 --- /dev/null +++ b/crates/engine/src/cmd/sweep_rails_tests.rs @@ -0,0 +1,143 @@ +//! Guide rails through the commands (issue #771): solid.sweep and surface.sweep with one rail +//! or two, scaling or stretching, checked against analytic values; editing, naming, saving and +//! hostile values. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::f64::consts::PI; + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A circle `C` (radius 10) on XY; a path `P` on XZ, a line 40 up the Z axis (`up`), with a rail +/// beside it (`in`) from the circle's edge at x = 10 in to x = 5 at the top; a line `L` across +/// XY from x = −10 to 10 (`l`); and in `R` on XZ two arcs from x = ±10 at the bottom to x = ±10 +/// at the top, bulging out to x = ±20 halfway (radius 25 about (∓5, 0, 20)). +fn setup() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"})); + // The XZ sketch's y runs down world Z. + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]})); + run(&mut s, "sketch.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "L"})); + run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "R"})); + run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [10, -40], "through": [20, -20], "id": "right"})); + run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [-10, -40], "through": [-20, -20], "id": "left"})); + run(&mut s, "sketch.finish", json!({})); + s +} + +fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure { + solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap() +} + +fn sweep(s: &mut Session, body: &str, extra: Value) -> Result { + let mut p = json!({"sketch": "C", "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("solid.sweep", &p).map_err(|e| e.to_string()) +} + +fn face_names(s: &mut Session, body: &str) -> Vec { + let f = run(s, "model.faces", json!({ "body": body })); + let mut n: Vec = f["faces"] + .as_array() + .into_iter() + .flatten() + .filter_map(|x| x["name"].as_str()) + .map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string()) + .collect(); + n.sort(); + n +} + +/// A rail closing in from radius 10 to 5 sweeps the circle into a frustum (and its curve into +/// the frustum's side); the faces keep the plain sweep's names; switching the fit to stretch is +/// an edit (the circle then widens only toward the rail: an elliptic cone, sections πr²k); the +/// rails are saved with the feature. +#[test] +fn a_rail_sweeps_a_frustum_and_stretching_is_an_edit() { + let mut s = setup(); + let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0); + let lateral = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt(); + sweep(&mut s, "Plain", json!({})).unwrap(); + sweep(&mut s, "Frustum", json!({"rails": [{"curves": ["in"]}], "name": "Railed"})).unwrap(); + let m = measure(&s, "Frustum"); + assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume); + assert_eq!(face_names(&mut s, "Plain"), face_names(&mut s, "Frustum")); + run( + &mut s, + "surface.sweep", + json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "P", "curves": ["in"]}], "body_name": "Side"}), + ); + let a = measure(&s, "Side").area; + assert!((a - lateral).abs() < 2e-3 * lateral, "side {a} vs {lateral}"); + + let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let reopened = Session::new(doc); + assert!((measure(&reopened, "Frustum").volume - m.volume).abs() < 1e-9 * volume); + + // Stretched: k = 1 − z/80 across (x) only, so sections are π · 100 · k. + run(&mut s, "timeline.edit", json!({"feature": "Railed", "set": {"rail_fit": "stretch"}})); + let stretched = measure(&s, "Frustum"); + let elliptic = PI * 100.0 * 40.0 * 0.75; + assert!((stretched.volume - elliptic).abs() < 2e-3 * elliptic, "stretched {} vs {elliptic}", stretched.volume); + assert!((stretched.bbox.max.y - 10.0).abs() < 1e-3 && (stretched.bbox.min.y + 10.0).abs() < 1e-3, "{:?}", stretched.bbox); +} + +/// Two arcs bulging apart carry a line between them: the sheet fills the region between the +/// arcs in the XZ plane, area 2(a√(R² − a²) + R² asin(a/R)) − 400 with R = 25, a = 20. +#[test] +fn two_rails_carry_a_line_between_arcs() { + let mut s = setup(); + run( + &mut s, + "surface.sweep", + json!({"sketch": "L", "curves": ["l"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "R", "curves": ["left"]}, {"sketch": "R", "curves": ["right"]}], "body_name": "Between"}), + ); + let m = measure(&s, "Between"); + let area = 2.0 * (20.0 * 15.0 + 625.0 * 0.8f64.asin()) - 400.0; + assert!((m.area - area).abs() < 1e-3 * area, "area {} vs {area}", m.area); + assert!((m.bbox.max.x - 20.0).abs() < 1e-2 && (m.bbox.min.x + 20.0).abs() < 1e-2, "{:?}", m.bbox); +} + +/// Hostile rails are refused with an error and leave the design as it was. +#[test] +fn hostile_rails_are_refused() { + let mut s = setup(); + let before = s.doc.features.len(); + let rail = json!([{"curves": ["in"]}]); + for bad in [ + json!({"rails": "in"}), + json!({"rails": []}), + json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}, {"curves": ["in"]}]}), + json!({"rails": [{"curves": []}]}), + json!({"rails": [{}]}), + json!({"rails": [{"curves": ["nope"]}]}), + json!({"rails": [{"sketch": "Nowhere", "curves": ["in"]}]}), + json!({"rails": rail, "rail_fit": "wobble"}), + json!({"rails": rail, "twist": "10 deg"}), + json!({"rails": rail, "scale": 2}), + json!({"rails": rail, "orientation": "parallel"}), + // A rail along the path itself: nothing to scale against. + json!({"rails": [{"curves": ["up"]}]}), + // Two rails starting at one point. + json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}]}), + ] { + assert!(sweep(&mut s, "Bad", bad.clone()).is_err(), "{bad}"); + let mut p = json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"]}); + p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}"); + assert_eq!(s.doc.features.len(), before, "{bad}"); + } +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 7d12b14d..1b94a447 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1835,7 +1835,7 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 { /// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of /// their chords). -pub(crate) fn path_polyline(path: &[PathSeg]) -> Result> { +pub fn path_polyline(path: &[PathSeg]) -> Result> { let first = match path.first() { Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a, None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())), diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 39110a8b..814d935a 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -114,6 +114,8 @@ mod surface_ruled; mod surface_untrim; mod surfaces; mod sweep_path; +#[cfg(test)] +mod sweep_rails_tests; mod tangent_cylinders; mod tess; #[cfg(test)] @@ -134,8 +136,8 @@ pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrate pub use boundary::{Boundary, boundary_patch}; pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid}; pub use build::{ - PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, revolve_whole, sphere, - sweep, sweep_with, torus, + PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, + revolve_whole, sphere, sweep, sweep_with, torus, }; pub use continuity::{ ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, @@ -180,7 +182,7 @@ pub use surfaces::{ copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch, }; -pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with}; +pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_path_with}; pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 2ba84275..1a7e8707 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -1,8 +1,9 @@ //! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves). //! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline //! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls -//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see -//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described. +//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale, guide +//! rails; see [`SweepControl`]) reshape the stations; their default leaves them exactly as +//! described. use mt::builder; use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; @@ -48,21 +49,44 @@ pub enum SweepOrientation { Direction(Vec3), } +/// How guide rails reshape the profile at each station. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RailFit { + /// The profile grows or shrinks evenly with its distance across to the rail (between the + /// two rails). + #[default] + Scale, + /// The profile stretches only across to the rail (between the rails), keeping its size the + /// other way. + Stretch, +} + /// Sweep options beyond following the path. `twist` turns the profile about the path by that /// many radians over the path's length (right hand about the direction of travel, in proportion /// to the length run); `scale` is the profile's size at the end, reached linearly along the -/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1) -/// is a plain sweep. -#[derive(Clone, Copy, Debug, PartialEq)] +/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1, +/// no rails) is a plain sweep. +/// +/// `rails` are guide rails, as polylines from the end nearest the profile. Each station of the +/// path is square to it; a rail meets the station's plane at one point. With one rail, the +/// profile turns about the path so that its point where the rail starts stays on the rail, and +/// is scaled (or stretched, see [`RailFit`]) by how far the rail is from the path compared with +/// the start. With two rails, the profile's points where the rails start stay on both: it moves +/// with the first rail, turns with the line between them and scales (stretches) with their +/// distance apart. Rails decide the profile's turn and size, so they take the default +/// orientation, no twist and scale 1, and a path without sharp corners. +#[derive(Clone, Debug, PartialEq)] pub struct SweepControl { pub orientation: SweepOrientation, pub twist: f64, pub scale: f64, + pub rails: Vec>, + pub rail_fit: RailFit, } impl Default for SweepControl { fn default() -> Self { - SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 } + SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0, rails: Vec::new(), rail_fit: RailFit::Scale } } } @@ -79,13 +103,15 @@ impl SweepControl { *self == SweepControl::default() } - /// Whether the profile changes along the path (it twists or scales). + /// Whether the profile changes along the path (it twists, scales or follows rails). fn varies(&self) -> bool { - self.twist != 0.0 || self.scale != 1.0 + self.twist != 0.0 || self.scale != 1.0 || !self.rails.is_empty() } /// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside - /// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length. + /// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length, + /// more than two rails, a rail that is not 2…100000 finite points, and rails with any other + /// orientation, a twist or an end scale. pub fn check(&self) -> Result<()> { if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) { return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into())); @@ -98,6 +124,17 @@ impl SweepControl { { return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into())); } + if self.rails.len() > 2 { + return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())); + } + for rail in &self.rails { + clean(rail).map_err(|_| KernelError::Invalid("a guide rail needs 2…100000 finite points along its length".into()))?; + } + if !self.rails.is_empty() && (self.orientation != SweepOrientation::Perpendicular || self.twist != 0.0 || self.scale != 1.0) { + return Err(KernelError::Invalid( + "guide rails set the profile's turn and size along the path: leave the orientation, twist and end scale at their defaults".into(), + )); + } Ok(()) } } @@ -228,6 +265,9 @@ fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result> { /// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and /// scale grow with the length run along `pts`. fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result> { + if !ctl.rails.is_empty() { + return railed_stations(pts, tangents, mitre, ctl); + } let bad = || KernelError::Invalid("sweep path".into()); let t0 = *tangents.first().ok_or_else(bad)?; let f: Vec<[Vec3; 3]> = match ctl.orientation { @@ -284,6 +324,113 @@ fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[ Ok(out) } +/// Where a rail polyline crosses the plane through `p` square to `t`: the first crossing at or +/// after `from` (segment index and fraction along it), with the position it was found at, so a +/// walk along the path finds each rail point after the last. Within `tol` of the plane a rail's +/// first and last points count as on it (a rail drawn to end where the path ends). +fn rail_crossing(rail: &[Vec3], p: Vec3, t: Vec3, from: (usize, f64), tol: f64) -> Option<(Vec3, (usize, f64))> { + let f = |q: Vec3| (q - p).dot(t); + let (k0, u0) = from; + if k0 == 0 && u0 == 0.0 && rail.first().is_some_and(|q| f(*q).abs() <= tol) { + return rail.first().map(|q| (*q, (0, 0.0))); + } + for k in k0..rail.len().saturating_sub(1) { + let (a, b) = (*rail.get(k)?, *rail.get(k + 1)?); + let a = if k == k0 { a.lerp(b, u0) } else { a }; + let (fa, fb) = (f(a), f(b)); + if fa == 0.0 && (k != k0 || u0 == 0.0) { + return Some((a, (k, if k == k0 { u0 } else { 0.0 }))); + } + if (fa < 0.0) != (fb < 0.0) || fb == 0.0 { + let s = fa / (fa - fb); + let q = a.lerp(b, s); + // The fraction along the whole segment, for the next walk. + let base = if k == k0 { u0 } else { 0.0 }; + return Some((q, (k, base + (1.0 - base) * s))); + } + } + let last = rail.last()?; + (f(*last).abs() <= tol).then(|| (*last, (rail.len().saturating_sub(2), 1.0))) +} + +/// Stations shaped by guide rails (see [`SweepControl`]): each is square to the path, turned +/// about it to follow the rail (the line between the rails) and scaled or stretched by the +/// rail's distance across (the rails' distance apart) against the start's. A rail has to meet +/// the plane of every station, in order along its length. +fn railed_stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result> { + // A polyline path's points bend it a little (its arcs' chords); a corner is a bend past + // [`CORNER`], where the stations would have to be mitred. + let corner = 1.0 / (CORNER / 2.0).cos(); + if mitre.iter().flatten().any(|(_, s)| *s > corner + 1e-12) { + return Err(KernelError::Invalid("not supported yet: guide rails along a path with sharp corners".into())); + } + let rails: Vec> = ctl.rails.iter().map(|r| clean(r)).collect::>()?; + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + let tol = 1e-3f64.max(1e-6 * len); + let mut cursors = vec![(0usize, 0.0f64); rails.len()]; + // At each station: the anchor the profile moves with, and the vector across that turns and + // sizes it (to the rail, or from the first rail to the second). + let mut across = Vec::with_capacity(pts.len()); + for (i, (p, t)) in pts.iter().zip(tangents).enumerate() { + let mut hits = Vec::with_capacity(rails.len()); + for (rail, cursor) in rails.iter().zip(cursors.iter_mut()) { + let Some((q, at)) = rail_crossing(rail, *p, *t, *cursor, tol) else { + let done = 100.0 * i as f64 / pts.len().saturating_sub(1).max(1) as f64; + return Err(KernelError::Invalid(format!( + "a guide rail does not reach the path's plane {done:.0}% of the way along it: the rail has to run the whole length of the path" + ))); + }; + *cursor = at; + hits.push(q); + } + let (anchor, v) = match hits[..] { + [r] => (*p, r - *p), + [a, b] => (a, b - a), + _ => return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())), + }; + across.push((anchor, v - *t * v.dot(*t))); + } + let (Some(&(a0, d0)), Some(&t0)) = (across.first(), tangents.first()) else { return Err(KernelError::Invalid("sweep path".into())) }; + let s0 = *pts.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?; + let near = |d: Vec3| d.len() <= 1e-6 * (1.0 + len); + if near(d0) { + return Err(KernelError::Invalid(if rails.len() == 1 { + "the guide rail starts on the path: it has to start away from the path, at the point of the profile it guides".into() + } else { + "the two guide rails start at the same point".into() + })); + } + let (u0, w0) = { + let u = d0.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?; + (u, t0.cross(u)) + }; + let mut out = Vec::with_capacity(pts.len()); + for ((anchor, d), t) in across.iter().zip(tangents) { + if near(*d) { + return Err(KernelError::Invalid(if rails.len() == 1 { + format!("the guide rail meets the path near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z) + } else { + format!("the guide rails meet near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z) + })); + } + let k = d.len() / d0.len(); + let k_other = match ctl.rail_fit { + RailFit::Scale => k, + RailFit::Stretch => 1.0, + }; + let u = d.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?; + let w = t.cross(u); + // Along the path stays along it; across to the rail turns onto the rail and sizes with + // it; the third way follows, scaled evenly or kept. + let map = |v: Vec3| *t * v.dot(t0) + u * (k * v.dot(u0)) + w * (k_other * v.dot(w0)); + let m = [map(Vec3::X), map(Vec3::Y), map(Vec3::Z)]; + let st = Station { at: Vec3::default(), m }; + // The anchor's start position lands on the anchor. + out.push(Station { at: *anchor - st.apply(a0 - s0), m }); + } + Ok(out) +} + /// The volume a profile of `area` on `plane` sweeps through the stations: each station's /// section area times how squarely the path crosses it, summed along the path (trapezoids). fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 { diff --git a/crates/kernel/src/sweep_rails_tests.rs b/crates/kernel/src/sweep_rails_tests.rs new file mode 100644 index 00000000..4bc438e3 --- /dev/null +++ b/crates/kernel/src/sweep_rails_tests.rs @@ -0,0 +1,166 @@ +//! Guide rails on path sweeps (issue #771), checked against analytic solids: a rail that closes +//! in on the path scales a circle into a cone frustum, a stretching rail widens a square one way +//! only, a helical rail twists a square, two rails carry a line between them; and rails the +//! sweep cannot follow are refused. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::f64::consts::PI; + +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::{RailFit, SweepControl, measure, sweep_path_with}; + +fn circle(r: f64) -> Region2 { + Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() } +} + +fn square(h: f64) -> Region2 { + Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() } +} + +/// The Z axis from 0 to 40. +fn up() -> Vec { + vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 40.0)] +} + +fn railed(rails: Vec>, fit: RailFit) -> SweepControl { + SweepControl { rails, rail_fit: fit, ..SweepControl::default() } +} + +fn close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * b.abs() +} + +/// A rail from the circle's edge at radius 10 in to radius 5 at the top scales the circle into +/// a frustum: volume πh(R² + Rr + r²)/3, lateral area π(R + r) times the slant height. +#[test] +fn a_rail_closing_in_scales_a_circle_into_a_frustum() { + let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &circle(10.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0); + let area = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt() + PI * 100.0 + PI * 25.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); + assert!(close(m.area, area, 2e-3), "area {} vs {area}", m.area); + assert!(close(m.bbox.max.z, 40.0, 1e-9), "{:?}", m.bbox); +} + +/// A stretching rail from x = 5 out to x = 10 doubles the square's width along X by the top and +/// leaves its depth along Y: sections 10 × 10k with k = 1 + z/40, volume 100 · 40 · 1.5. +#[test] +fn a_stretching_rail_widens_a_square_one_way() { + let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Stretch)).unwrap(); + let m = measure(&b).unwrap(); + assert!(close(m.volume, 6000.0, 1e-3), "volume {}", m.volume); + assert!((m.bbox.max.x - 10.0).abs() < 1e-3 && (m.bbox.min.x + 10.0).abs() < 1e-3, "{:?}", m.bbox); + assert!((m.bbox.max.y - 5.0).abs() < 1e-3 && (m.bbox.min.y + 5.0).abs() < 1e-3, "{:?}", m.bbox); +} + +/// The same rail scaling evenly: the square grows both ways, sections 100k², volume +/// 100 ∫ (1 + z/40)² dz = 100 · 40 · 7/3. +#[test] +fn a_scaling_rail_grows_a_square_both_ways() { + let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = 100.0 * 40.0 * 7.0 / 3.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); + assert!((m.bbox.max.y - 10.0).abs() < 1e-3, "{:?}", m.bbox); +} + +/// A helical rail from the square's corner, a quarter turn about the path at the corner's +/// radius, twists the square a quarter turn: the volume stays 4000, halfway up the corners stand +/// on the axes (the box reaches 5√2), and at the top the corner is at (−5, 5). +#[test] +fn a_helical_rail_twists_a_square() { + let r = 50f64.sqrt(); + let rail: Vec = (0..=200) + .map(|i| { + let u = i as f64 / 200.0; + let a = PI / 4.0 + u * PI / 2.0; + Vec3::new(r * a.cos(), r * a.sin(), 40.0 * u) + }) + .collect(); + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + assert!(close(m.volume, 4000.0, 2e-3), "volume {}", m.volume); + assert!((m.bbox.max.x - r).abs() < 0.05 && (m.bbox.max.y - r).abs() < 0.05, "{:?}", m.bbox); + // The edge running up from the corner (5, 5) ends a quarter turn on, at (−5, 5). + let edges = b.edges(0.01).unwrap(); + let ends = |e: &crate::EdgeInfo| (e.points.first().copied().unwrap(), e.points.last().copied().unwrap()); + let joins = |p: Vec3, q: Vec3| { + edges.iter().any(|e| { + let (a, b) = ends(e); + (a.dist(p) < 1e-3 && b.dist(q) < 1e-3) || (a.dist(q) < 1e-3 && b.dist(p) < 1e-3) + }) + }; + assert!(joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(-5.0, 5.0, 40.0)), "{:?}", edges.iter().map(ends).collect::>()); + assert!(!joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 40.0))); +} + +/// Along a quarter circle of radius 30, a rail keeping 5 mm out from the path (the circle's +/// outer point, on a quarter circle of radius 35) leaves the circle as it is: a quarter torus, +/// volume πr² · R · π/2 (Pappus). +#[test] +fn a_parallel_rail_along_a_curved_path_keeps_the_section() { + let arc = |radius: f64| -> Vec { + (0..=180) + .map(|i| { + let a = PI / 2.0 * i as f64 / 180.0; + Vec3::new(30.0 - radius * a.cos(), 0.0, radius * a.sin()) + }) + .collect() + }; + let b = sweep_path_with(&Plane::XY, &circle(5.0), &arc(30.0), &railed(vec![arc(35.0)], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = PI * 25.0 * 30.0 * PI / 2.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); +} + +/// Two rails bulging apart along arcs (radius 25 about (∓5, 0, 20), through x = ±10 at z = 0 +/// and z = 40) carry a line between them: the sheet lies in the XZ plane between the arcs, area +/// 2(a√(R² − a²) + R² asin(a/R)) − 400 with a = 20. +#[test] +fn two_rails_carry_a_line_between_arcs() { + let arc = |side: f64| -> Vec { + (0..=400) + .map(|i| { + let z = 40.0 * i as f64 / 400.0; + let x = -5.0 + (625.0 - (z - 20.0) * (z - 20.0)).sqrt(); + Vec3::new(side * x, 0.0, z) + }) + .collect() + }; + let line = vec![vec![Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }]]; + for fit in [RailFit::Scale, RailFit::Stretch] { + let ctl = railed(vec![arc(-1.0), arc(1.0)], fit); + let b = crate::sweep_sheet_points_with(&Plane::XY, &line, &up(), &ctl).unwrap(); + let m = measure(&b).unwrap(); + let area = 2.0 * (20.0 * 15.0 + 625.0 * (0.8f64).asin()) - 400.0; + assert!(close(m.area, area, 1e-3), "{fit:?}: area {} vs {area}", m.area); + assert!(m.bbox.min.y.abs() < 1e-6 && m.bbox.max.y.abs() < 1e-6, "{fit:?}: {:?}", m.bbox); + } +} + +/// Rails the sweep cannot follow are refused with a reason, never a panic: three rails, a rail +/// with a bad point, a rail with a twist or scale besides, a rail starting on the path, a rail +/// that stops short, rails along a path with a corner. +#[test] +fn rails_the_sweep_cannot_follow_are_refused() { + let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)]; + let refuse = |ctl: SweepControl, path: &[Vec3], why: &str| { + let e = sweep_path_with(&Plane::XY, &circle(10.0), path, &ctl).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains(why), "expected `{why}` in `{e}`"); + }; + refuse(railed(vec![rail.clone(); 3], RailFit::Scale), &up(), "one or two guide rails"); + refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)]], RailFit::Scale), &up(), "finite points"); + refuse(SweepControl { twist: 1.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation"); + refuse(SweepControl { scale: 2.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation"); + refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(5.0, 0.0, 40.0)]], RailFit::Scale), &up(), "starts on the path"); + refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(8.0, 0.0, 20.0)]], RailFit::Scale), &up(), "whole length"); + refuse(railed(vec![rail.clone(), rail.clone()], RailFit::Scale), &up(), "start at the same point"); + let bent = vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)]; + refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 30.0)]], RailFit::Scale), &bent, "sharp corners"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 31dbb142..748117da 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -565,10 +565,16 @@ impl Dialog { vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)], ), "solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), - "solid.sweep" => Dialog::new( - Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, - vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()], - ), + "solid.sweep" => Dialog::new(Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, { + let [rail, rail2] = crate::dialogs_sweep::rail_inputs(); + vec![ + SelInput::new("Profile", PROFILES, true), + SelInput::new("Path", CURVES, true), + crate::dialogs_sweep::direction_input(), + rail, + rail2, + ] + }), "solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]), "solid.box" => Dialog::new( @@ -3900,7 +3906,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S let path: Vec = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?; let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}); - crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?; + crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), [sels(d, 3), sels(d, 4)], &mut p)?; ("solid.sweep", p) } Kind::OffsetPlane { offset } => { @@ -5005,13 +5011,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, rails, rail_fit, .. } => { let mut d = start("solid.sweep")?; - let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component)); + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, &feature_frame(s, f.component)); d.kind = Kind::Sweep { operation: op_index(operation), opts }; if let Some(inp) = d.inputs.get_mut(2) { inp.items = direction.into_iter().collect(); } + for (inp, items) in d.inputs.iter_mut().skip(3).zip(crate::dialogs_sweep::rail_sels(rails)) { + inp.items = items; + } let items = st.sketch(sketch.0).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: sketch.0, index }).collect(); diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 74266488..2c5375fc 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -117,11 +117,16 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)], - Surf::Sweep(_) => vec![ - SelInput::new("Profiles or curves", PROFILES | CURVES, true), - SelInput::new("Path", CURVES, true), - crate::dialogs_sweep::direction_input(), - ], + Surf::Sweep(_) => { + let [rail, rail2] = crate::dialogs_sweep::rail_inputs(); + vec![ + SelInput::new("Profiles or curves", PROFILES | CURVES, true), + SelInput::new("Path", CURVES, true), + crate::dialogs_sweep::direction_input(), + rail, + rail2, + ] + } Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)], Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], @@ -371,7 +376,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { @@ -610,14 +615,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K } return Some((Kind::Surface(k), ins, extra)); } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => { - let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame); + FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, rails, rail_fit, .. } => { + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, frame); + let [rail, rail2] = crate::dialogs_sweep::rail_sels(rails); ( Surf::Sweep(opts), vec![ sheet_sels(app, sketch.0, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(), direction.into_iter().collect(), + rail, + rail2, ], ) } diff --git a/crates/ui-egui/src/dialogs_sweep.rs b/crates/ui-egui/src/dialogs_sweep.rs index dd5e2334..cc3a716e 100644 --- a/crates/ui-egui/src/dialogs_sweep.rs +++ b/crates/ui-egui/src/dialogs_sweep.rs @@ -1,14 +1,15 @@ //! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a -//! twist over the path's length and the profile's scale at the end. +//! twist over the path's length, the profile's scale at the end, and guide rails with how they +//! reshape the profile. use serde_json::{Value, json}; use solidcraft_engine::Sel; -use solidcraft_engine::doc::{Expr, Mat, SweepOrientation}; +use solidcraft_engine::doc::{Expr, Mat, RailFit, SweepOrientation, SweepRail}; use solidcraft_engine::geom::Vec3; use crate::SolveApp; use crate::dialogs::{combo, field, row_label}; -use crate::selection::{AXES, EDGES, SelInput}; +use crate::selection::{AXES, CURVES, EDGES, SelInput}; /// The orientation choices, in combo order: perpendicular, parallel, a direction. const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"]; @@ -18,6 +19,8 @@ const DIRECTION: usize = 2; const NO_TWIST: &str = "0 deg"; /// An end scale field that means no scaling. const NO_SCALE: &str = "1"; +/// The rail fits, in combo order: scale, stretch. +const RAIL_FITS: [&str; 2] = ["Scale", "Stretch"]; /// A sweep dialog's options. #[derive(Clone, Debug, PartialEq)] @@ -28,11 +31,13 @@ pub struct SweepOpts { pub scale: String, /// A stored direction (world), used while no direction is picked. pub direction: Option, + /// An index into [`RAIL_FITS`]. + pub rail_fit: usize, } impl Default for SweepOpts { fn default() -> Self { - SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None } + SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None, rail_fit: 0 } } } @@ -40,10 +45,17 @@ impl SweepOpts { /// A feature's stored options as dialog values; `frame` maps the feature's component frame to /// the world (a stored direction is in the component's frame). A direction along an origin /// axis comes back as a pick of that axis. - pub fn of(orientation: &SweepOrientation, twist: &Option, scale: &Option, frame: &Mat) -> (SweepOpts, Option) { + pub fn of( + orientation: &SweepOrientation, + twist: &Option, + scale: &Option, + rail_fit: RailFit, + frame: &Mat, + ) -> (SweepOpts, Option) { let mut o = SweepOpts { twist: twist.as_ref().map_or_else(|| NO_TWIST.into(), |t| t.to_string()), scale: scale.as_ref().map_or_else(|| NO_SCALE.into(), |k| k.to_string()), + rail_fit: usize::from(rail_fit == RailFit::Stretch), ..SweepOpts::default() }; let mut pick = None; @@ -74,6 +86,18 @@ pub fn direction_input() -> SelInput { SelInput::new("Direction", AXES | EDGES, false) } +/// The inputs that pick a sweep's guide rails: the first rail's curves and the second's, in order +/// along each rail; both optional. +pub fn rail_inputs() -> [SelInput; 2] { + [SelInput::new("Rail", CURVES, true), SelInput::new("Second Rail", CURVES, true)] +} + +/// A feature's stored rails as the rail inputs' selections. +pub fn rail_sels(rails: &[SweepRail]) -> [Vec; 2] { + let sels = |i: usize| rails.get(i).map(|r| r.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()).unwrap_or_default(); + [sels(0), sels(1)] +} + /// The option rows; true when Enter was pressed in a field. pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool { let mut enter = false; @@ -86,12 +110,34 @@ pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool { row_label(ui, "End Scale"); enter |= field(ui, &mut o.scale); ui.end_row(); + row_label(ui, "Rail Fit"); + combo(ui, &format!("{id}_rail_fit"), &RAIL_FITS, &mut o.rail_fit); + ui.end_row(); enter } /// The options as command parameters, added to `p`: the orientation, and the twist and end -/// scale when they change anything. `picked` is the direction input's selection. -pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> { +/// scale when they change anything; with rails picked, the rails (each in the sketch its curves +/// are in) and the fit instead. `picked` is the direction input's selection, `rails` the rail +/// inputs'. +pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, rails: [&[Sel]; 2], p: &mut Value) -> Result<(), String> { + let mut given = Vec::new(); + for sels in rails { + let curves: Vec = sels.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); + if curves.is_empty() { + continue; + } + let sketch = crate::dialogs::curves_sketch(app, &curves, None).ok_or("each rail's curves must be in one sketch")?; + given.push(json!({"sketch": sketch, "curves": curves})); + } + if !given.is_empty() { + // Rails set the profile's turn and size: the other options don't apply. + p["rails"] = json!(given); + if o.rail_fit == 1 { + p["rail_fit"] = json!("stretch"); + } + return Ok(()); + } match o.orientation { 0 => {} 1 => p["orientation"] = json!("parallel"), diff --git a/crates/ui-egui/src/sweep_dialog_tests.rs b/crates/ui-egui/src/sweep_dialog_tests.rs index a2803e2d..60c54b00 100644 --- a/crates/ui-egui/src/sweep_dialog_tests.rs +++ b/crates/ui-egui/src/sweep_dialog_tests.rs @@ -99,3 +99,42 @@ fn editing_sweeps_keeps_their_options() { assert_eq!(after.param_names, before.param_names, "{name}"); } } + +/// A rail picked in the dialog reaches the command with its sketch, the fit with it; editing the +/// sweep brings both back and keeps them. +#[test] +fn a_rail_picked_in_the_dialog_is_kept_through_an_edit() { + let mut h = Harness::new(); + let sketch = setup(&mut h); + h.app.run("sketch.create", json!({"plane":"XZ","name":"R"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[5,0],[10,-40]],"ids":["out"]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let rail_sketch = h.app.session.doc.find_feature("R").unwrap().id; + h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap(); + h.frames(3); + assert!(h.widget("Rail Fit").is_some()); + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }]; + d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }]; + d.inputs[3].items = vec![Sel::SketchCurve { id: "out".into() }]; + let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) }; + opts.rail_fit = 1; + h.frames(2); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + let name = h.app.session.doc.features.last().unwrap().name.clone(); + let rails = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind { + FeatureKind::Sweep { rails, rail_fit, .. } => (rails.clone(), *rail_fit), + k => panic!("{k:?}"), + }; + let (stored, fit) = rails(&h); + assert_eq!(stored, vec![solidcraft_engine::doc::SweepRail { sketch: solidcraft_engine::doc::SketchId(rail_sketch), curves: vec!["out".into()] }]); + assert_eq!(fit, solidcraft_engine::doc::RailFit::Stretch); + h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap(); + h.frames(8); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[3].items, vec![Sel::SketchCurve { id: "out".into() }]); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + assert_eq!(rails(&h), (stored, fit)); +} From 7f2ae11a062053119a98fe22ba99f731342f06f6 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:07:50 +0200 Subject: [PATCH 094/149] Regression test: a box intersect splits the end off a raked wedge after its end rounds (#1617) (#1862) Thanks @SychPL. --- crates/engine/src/boolean_regression_tests.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 5f89fc15..28b166d8 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -312,3 +312,68 @@ fn joined_loft_frames_plan_their_display_at_every_quality() { } } } + +/// The wedge of #1617: a side profile in (y, z) extruded 138 mm along x, its back face leaning +/// by `lean` (the tangent of its angle), with the issue's rounds. The last one runs along the +/// outline of both ends. The issue asked for R1 with `fit: reduce`, which reduces it to 0.854 mm; +/// that radius is asked for here directly, which builds the same body without the fit's search +/// (about a minute). +fn raked_wedge(lean: f64) -> Session { + let mut s = Session::default(); + let top = 60.0 - 60.0 * lean; + let profile = [[-60.0, 0.0], [60.0, 0.0], [top, 60.0], [38.0, 60.0], [-22.0, 22.0], [-60.0, 22.0]]; + let back = (60.0 + top) / 2.0; + let ends: Vec<[f64; 3]> = [-69.0, 69.0].iter().flat_map(|&x| [[x, -41.0, 22.0], [x, 8.0, 41.0], [x, (38.0 + top) / 2.0, 60.0]]).collect(); + let steps = [ + ("sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Profile"})), + ("sketch.line", json!({"points": profile, "closed": true})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 138, "direction": "symmetric", "operation": "new", "body_name": "Wedge"})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.2, "edges": [[0, -60, 22]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.5, "edges": [[0, -22, 22]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.5, "edges": [[0, 38, 60]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 4, "edges": [[-69, -60, 12], [69, -60, 12], [-69, back, 30], [69, back, 30]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 0.854, "edges": ends})), + ]; + for (command, params) in steps { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")); + } + s +} + +/// A box intersect taking the end slice x >= 55 off the wedge of #1617 failed with "no result" +/// once its ends were rounded, when its back face leaned 10 degrees (a vertical one worked). +/// The leaning back shortens the top edge the end round runs along to 11.4 mm. Its strip there +/// borrowed sections from that short neighbour and folded back on itself past the joint, and +/// the boolean could not intersect the folded faces (fixed with #1667). The slice and the +/// rest, cut off by the same box, are sound and add up to the wedge, with the cut plane where +/// the issue had it and moved by 0.02 mm, as it also tried. +#[test] +fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() { + for lean in [10f64.to_radians().tan(), 0.0] { + let mut s = raked_wedge(lean); + let whole = volume(&s, "Wedge"); + for (i, x0) in [55.0, 55.02].into_iter().enumerate() { + let mut piece = |op: &str| -> (f64, solidcraft_geom::Aabb3) { + let copy = s.execute("solid.move", &json!({"bodies": ["Wedge"], "translate": [0, 0, 0], "copy": true})).unwrap(); + let copy = copy["created_bodies"][0].as_str().unwrap().to_string(); + let cap = format!("Cap{i}{op}"); + let length = 80.0 - x0; + let cap_box = json!({"center": [x0 + length / 2.0, 0, 40], "length": length, "width": 160, "height": 120, "operation": "new", "body_name": cap}); + s.execute("solid.box", &cap_box).unwrap(); + let (target, tool) = if op == "intersect" { (cap, copy) } else { (copy, cap) }; + s.execute("solid.combine", &json!({"target": target, "tools": [tool], "operation": op})) + .unwrap_or_else(|e| panic!("lean {lean}, x >= {x0}, {op}: {e}")); + let state = s.model.state(); + let body = &state.body(&target).unwrap().body; + assert!(body.validity().is_empty(), "lean {lean}, x >= {x0}, {op}: {:?}", body.validity()); + (solidcraft_kernel::measure(body).unwrap().volume, body.tessellate(1e-3).unwrap().bounds()) + }; + let (slice, at) = piece("intersect"); + let (rest, _) = piece("cut"); + println!("#1617 lean {lean:.4}, x >= {x0}: slice {slice:.6} + rest {rest:.6} = {:.6}, wedge {whole:.6} mm³", slice + rest); + assert!((slice + rest - whole).abs() < 1e-6 * whole, "{slice} + {rest} vs {whole}"); + assert!((at.min.x - x0).abs() < 1e-6 && (at.max.x - 69.0).abs() < 1e-3, "slice spans x {} to {}", at.min.x, at.max.x); + } + } +} From ddc6aa63012ba69c13bb13d29d0eb57cbf13f049 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:07:53 -0400 Subject: [PATCH 095/149] docs: CLAUDE.md carries the current shared agent rules (#1822) (#1859) Thanks @DuBearte. --- CLAUDE.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ed78acd..82492953 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend operations are our own. - **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or - artwork from commercial CAD products — ever.** Every icon is drawn in code - (`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** + artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code + (`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** (`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed. Breaking this rule is the most serious mistake you can make in this repo. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` @@ -81,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)`. Colours come from `theme::Tokens`. +- **One set of camera controls**: every page or canvas with a camera (viewport, Render + preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps` + (orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll + camera input (a test rejects it). - **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`. @@ -96,6 +100,29 @@ People trust SolidCraft with their designs; a crash loses their work. **This out - **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). +## Showing and following through on changes + +- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior, + issues and PRs must include actual descriptive screenshots or a short recording from a + small reproduction. This includes kernel, geometry, assembly and backend changes whose + effects can be shown in the app. Coding agents should run the app and capture this evidence + themselves; do not ask a human for evidence the agent can produce. Show the actual failure + or result, preferably before and after, with brief reproduction steps, expected/actual + behavior and annotations that point out the relevant detail. +- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible + behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate + the point. Changes with no useful observable behavior do not need screenshot or video evidence. +- Use a small public-safe example and check for private designs, paths, account details or + other sensitive content before sharing. Attach media to the issue/PR or link to an accessible + recording; do not commit generated screenshots or videos just to provide review evidence. +- Throughout the work and before handoff, check current upstream commits and the relevant + PR's merge status. Keep your branch current with upstream merges, resolve conflicts without + discarding others' work, and re-run validation when integration changes the result. Use + `git pull --rebase origin main` before pushing as above; never force-push. Report the checked + upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open, + keep monitoring it while the task is active and make its pending status clear at handoff; + do not claim it merged without checking. + ## Claim = a draft PR - Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`. - If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push, From 3689f0d6e5d6a814d554c0d8a16813ee47c7bf71 Mon Sep 17 00:00:00 2001 From: Karl Von Richter Date: Sat, 17 Oct 2026 18:07:56 +1000 Subject: [PATCH 096/149] ui(feat): open expression suggestions with Ctrl+Space (#1852) Thanks @Karl-VonRichter. --- .../ui-egui/src/dimension_create_ui_tests.rs | 33 ++++++++++++++++- crates/ui-egui/src/expression_complete.rs | 37 +++++++++++++++++-- crates/ui-egui/src/params_dialog.rs | 14 +++++-- docs/book/src/guide/parameters.md | 4 +- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 7cfd1910..fc013b22 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -26,8 +26,39 @@ fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) { } fn type_value(h: &mut Harness, text: &str) { h.step(&json!({"key":"A","cmd":true})).unwrap(); - h.step(&json!({"text":text})).unwrap(); + if text.is_empty() { + h.step(&json!({"key":"Backspace"})).unwrap(); + } else { + h.step(&json!({"text":text})).unwrap(); + } } +#[test] +fn dimension_parameters_use_typing_and_ctrl_space_completion() { + for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] { + let mut h = line([20., 0.]); + h.app.run("parameters.add", json!({"name":"width","expression":"25 mm"})).unwrap(); + if prefix == "d" { + h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap(); + h.settle(); + } + place(&mut h, &[[10., 0.]], [10., 6.]); + let baseline = h.app.session.doc.to_json(); + type_value(&mut h, prefix); + if prefix.is_empty() { + h.step(&json!({"key":"Space","cmd":true})).unwrap(); + } + assert!(h.widget(expected).is_some(), "parameter completion is available for {prefix:?}: {}", h.call("ui.inspect", json!({}))); + h.step(&json!({"key":"Tab"})).unwrap(); + assert!(crate::dim_view::editing(), "accepting completion does not apply the dimension"); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.doc.params.iter().rev().find(|p| p.model).unwrap().expr, expected); + crate::dim_view::cancel(); + } +} + #[test] fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() { for (end, picks, at) in [ diff --git a/crates/ui-egui/src/expression_complete.rs b/crates/ui-egui/src/expression_complete.rs index 26df7cd5..67388312 100644 --- a/crates/ui-egui/src/expression_complete.rs +++ b/crates/ui-egui/src/expression_complete.rs @@ -2,7 +2,7 @@ use egui::{Key, RichText}; -use crate::params_dialog::{suggestions, word_before}; +use crate::params_dialog::{suggestions_for, word_before}; use crate::theme::Tokens; #[derive(Clone)] @@ -15,6 +15,7 @@ struct Popup { count: usize, accept: bool, dismissed: bool, + explicit: bool, } fn popup_id() -> egui::Id { @@ -25,6 +26,17 @@ fn popup_id() -> egui::Id { /// owns these keys. Raw events are removed too: several expression editors inspect raw Tab. pub(crate) fn begin_frame(ctx: &egui::Context) { let Some(mut popup) = ctx.data(|d| d.get_temp::(popup_id())) else { return }; + if ctx.memory(|m| m.has_focus(popup.editor)) + && popup.frame.saturating_add(1) >= ctx.cumulative_frame_nr() + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, Key::Space)) + { + ctx.input_mut(|i| { + i.raw.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Space, modifiers, .. } if modifiers.ctrl)); + }); + popup.explicit = true; + popup.dismissed = false; + ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone())); + } let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor)); let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred; if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 { @@ -88,7 +100,6 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri let state = egui::TextEdit::load_state(&ctx, resp.id); let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count()); let (start, word) = word_before(text, cursor); - let list = suggestions(&ctx, &word); let previous = ctx.data(|d| d.get_temp::(popup_id())); let mut popup = previous.filter(|p| p.editor == resp.id && p.word == word && p.cursor == cursor).unwrap_or(Popup { editor: resp.id, @@ -96,10 +107,12 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri word, cursor, selected: 0, - count: list.len(), + count: 0, accept: false, dismissed: false, + explicit: false, }); + let list = suggestions_for(&ctx, &popup.word, popup.explicit); popup.frame = ctx.cumulative_frame_nr(); popup.count = list.len(); popup.selected = popup.selected.min(list.len().saturating_sub(1)); @@ -153,6 +166,7 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri #[cfg(test)] mod tests { use super::*; + use crate::params_dialog::suggestions; use crate::{SolveApp, params_dialog}; use serde_json::json; @@ -308,6 +322,23 @@ mod tests { assert!(e.leaked.contains(&Key::Enter)); } + #[test] + fn ctrl_space_opens_empty_expression_suggestions_and_reopens_dismissed_ones() { + for prefix in ["", "wi"] { + let mut e = Editor::new(prefix); + if !prefix.is_empty() { + e.key(Key::Escape); + } + e.frame(vec![egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::CTRL }]); + let popup = e.ctx.data(|d| d.get_temp::(popup_id())).unwrap(); + assert!(popup.explicit && !popup.dismissed && popup.count > 0); + assert_eq!(e.text, prefix, "Ctrl+Space does not add a space"); + e.key(Key::Tab); + assert!(e.accepted); + assert!(!e.text.is_empty()); + } + } + #[test] fn modified_navigation_remains_available_to_the_editor() { let mut e = Editor::new("wi"); diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 8752588d..23bdb450 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -498,16 +498,24 @@ pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) { /// Suggestions for the word being typed. pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec { - if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { + suggestions_for(ctx, word, false) +} + +pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -> Vec { + if (!explicit && word.is_empty()) || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Vec::new(); } let all = ctx.data(|d| d.get_temp::(cache_id())).map(|c| c.all).unwrap_or_default(); // A complete token is ready to submit, even when longer names share its prefix. - if all.iter().any(|s| s.text == word) { + if !explicit && all.iter().any(|s| s.text == word) { return Vec::new(); } let w = word.to_ascii_lowercase(); - all.iter().filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && s.text.trim_end_matches('(') != word).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. diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 5aa1f18a..c3edf3ea 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -11,7 +11,9 @@ parameter editor. The separate **Configurations** button opens the configuration Press **Enter** in the new parameter's name, expression or comment field to add it. Valid entries return focus to the name field for the next parameter; invalid entries remain -editable. In expression fields, **Up/Down** selects an autocomplete suggestion, +editable. In expression fields, type a parameter or dimension name for autocomplete, +or press **Ctrl+Space** to open suggestions, including in an empty field. +**Up/Down** selects an autocomplete suggestion, **Enter/Tab** accepts it, and **Escape** dismisses the suggestions. Accepting a suggestion does not also submit the parameter. From 39850fb6ab5bc859a46cffbfd8c4657282913b92 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:08:03 -0400 Subject: [PATCH 097/149] Extrude preview: a New Body has its own colour, apart from Join (#1805) (#1861) Thanks @DuBearte. --- assets/styles/ui/palettes.rs | 1 + crates/ui-egui/src/colour_vision_tests.rs | 1 + crates/ui-egui/src/dialogs_assembly.rs | 2 +- crates/ui-egui/src/dialogs_assembly_tests.rs | 24 +++++++++++++++++--- crates/ui-egui/src/preview.rs | 15 +++++++++--- crates/ui-egui/src/preview_drag_tests.rs | 2 +- crates/ui-egui/src/theme.rs | 2 ++ 7 files changed, 39 insertions(+), 8 deletions(-) diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 46683450..afe8f013 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -48,6 +48,7 @@ impl Tokens { body_edge: Color32::from_rgb(40, 44, 52), manipulator: Color32::from_rgb(40, 110, 220), preview_add: Color32::from_rgb(112, 160, 226), + preview_new: Color32::from_rgb(240, 225, 105), preview_sketch: Color32::from_rgb(32, 190, 96), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), cube_face: Color32::from_rgb(236, 239, 244), diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 5bba2ea7..63eccee7 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -292,6 +292,7 @@ fn groups(t: &Tokens) -> Vec { ("body_selected", t.body_selected), ("hover_face_lift", lift(t.body)), ("preview_add", t.preview_add), + ("preview_new", t.preview_new), ], &[], ), diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 4294c0fe..90c7659f 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -1001,7 +1001,7 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O .map(|(_, r)| r.clone()); let (before, after) = (preview.before, preview.after); // A moved component looks like itself (no new surface), with its old place as the ghost. - let colors = Colors { added: colors.body, ..colors }; + let colors = Colors { added: colors.body, new_body: colors.body, ..colors }; let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors))); Some(match built { Ok(mut b) => { diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 2e36408c..c8dffb0a 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -74,7 +74,13 @@ fn joint_and_drive_preview_on_the_placed_model() { let mut d = app.dialog.clone().unwrap(); d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap()); d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap()); - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let cmds = preview_commands(&app, &d).unwrap(); let b = world_preview(&app.session, &cmds, colors).unwrap().unwrap(); assert_eq!(b.replaced, vec!["Body2".to_string()], "B moves"); @@ -461,7 +467,13 @@ fn four_bar() -> SolveApp { #[test] fn drive_says_where_a_loop_stopped_it() { let mut app = four_bar(); - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let rocker = app.session.doc.assembly.joints.iter().position(|j| j.name == "Rocker").unwrap(); let mut d = drive_joint(&app, app.session.doc.assembly.joints[rocker].id).unwrap(); let ask = |d: &mut Dialog, v: &str| { @@ -514,7 +526,13 @@ fn drive_says_when_its_joint_is_joined_to_nothing_grounded() { if let Kind::Assembly(Asm::Drive { values, .. }) = &mut d.kind { values[0] = "45 deg".into(); } - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let b = world_preview(&app.session, &apply_commands(&app, &d).unwrap(), colors).unwrap().unwrap(); let why = b.stopped.as_ref().and_then(drive_stop).unwrap(); assert!(why.starts_with("Moves nothing") && why.contains("C:1"), "{why}"); diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 04e00bb3..41a2ab13 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -26,6 +26,8 @@ use crate::theme::Tokens; pub struct Colors { pub body: [u8; 4], pub added: [u8; 4], + /// A body the preview creates (New Body), so it doesn't look like a join. + pub new_body: [u8; 4], pub removed: [u8; 4], pub edge: [u8; 4], } @@ -33,7 +35,7 @@ pub struct Colors { impl Colors { pub fn from_tokens(t: &Tokens) -> Colors { let c = |c: egui::Color32| c.to_srgba_unmultiplied(); - Colors { body: c(t.body), added: c(t.preview_add), removed: c(t.preview_cut), edge: c(t.body_edge) } + Colors { body: c(t.body), added: c(t.preview_add), new_body: c(t.preview_new), removed: c(t.preview_cut), edge: c(t.body_edge) } } } @@ -501,7 +503,11 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6); for (t, f) in am.triangles.iter().zip(&am.tri_face) { let new = added.get(*f as usize).copied().unwrap_or(true); - let col = if new { c.added } else { c.body }; + let col = match (new, old.is_some()) { + (false, _) => c.body, + (true, true) => c.added, + (true, false) => c.new_body, + }; for k in t { let i = *k as usize; if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) { @@ -717,7 +723,7 @@ mod tests { use serde_json::json; fn colors() -> Colors { - Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], new_body: [4, 4, 4, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } } #[test] @@ -1006,6 +1012,9 @@ mod tests { let b = compute(&s, &[("solid.extrude".into(), json!({"distance": "15 mm"}))], colors()).unwrap(); assert!(b.replaced.is_empty()); assert_eq!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().body), 0); + // #1805: a new body has its own colour, not a join's. + assert!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().new_body) > 0); + assert_eq!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().added), 0); assert!(b.scene.ghost.is_empty()); assert!(compute(&s, &[("solid.extrude".into(), json!({"distance": "1 +"}))], colors()).is_err()); assert!(s.model.state().bodies.is_empty()); diff --git a/crates/ui-egui/src/preview_drag_tests.rs b/crates/ui-egui/src/preview_drag_tests.rs index 24f56f7b..bcaa3afa 100644 --- a/crates/ui-egui/src/preview_drag_tests.rs +++ b/crates/ui-egui/src/preview_drag_tests.rs @@ -3,7 +3,7 @@ use super::*; use serde_json::json; fn colors() -> Colors { - Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], new_body: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } } fn fixture() -> Session { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 629b12e0..101c8fc1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -208,6 +208,8 @@ pub struct Tokens { pub manipulator: Color32, /// New surface in a feature preview. pub preview_add: Color32, + /// The surface of a body a feature preview creates (New Body), apart from a join (#1805). + pub preview_new: Color32, /// Proposed sketch geometry, distinct from blue selections. pub preview_sketch: Color32, /// Removed material in a feature preview (translucent). From cd9db57366171b53c10b90fef8997c69fb76e9a6 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:08:09 -0400 Subject: [PATCH 098/149] Look At pressed again looks from the other side (#1886) (#1904) Thanks @DuBearte. --- crates/ui-egui/src/dialogs.rs | 23 ++++++++++++++++++++--- crates/ui-egui/src/navigation.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/sketch_palette.rs | 2 +- crates/ui-egui/src/viewport.rs | 7 ++++--- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 748117da..ff9703e2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3505,19 +3505,36 @@ fn plane_by_name(app: &SolveApp, name: &str) -> Option 0.999 { + if n.z.abs() > 0.999 { let x = ss.plane.x; - to.yaw = if ss.plane.normal().z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; + to.yaw = if n.z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; } to.target = ss.plane.origin; app.animate_to(to); } } +/// The side to look from at a plane with normal `n`: its own side, or with `again` the other +/// side when the view already looks from `n`, so a second Look At turns the view round (#1886). +pub(crate) fn facing(app: &SolveApp, n: Vec3, again: bool) -> Vec3 { + if again && app.cam.back().dot(n) > 0.999 { -n } else { n } +} + fn sels(d: &Dialog, i: usize) -> &[Sel] { d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) } diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index aed11634..383f1bf7 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -915,4 +915,26 @@ mod view_key_tests { } assert!(crate::keymap::RESERVED.contains(&crate::keymap::key_name(egui::Key::Num8, egui::Modifiers::COMMAND).as_str())); } + + /// #1886: Ctrl+8 looks straight at the sketch; pressed again while the view faces it, it + /// looks from the other side, and a third press turns it back. Opening a sketch never flips. + #[test] + fn ctrl_8_again_looks_from_the_other_side() { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.frames(2); + let back_z = |h: &Harness| h.app.cam.back().z; + for want in [1.0, -1.0, 1.0] { + h.step(&json!({"key": "8", "cmd": true})).unwrap(); + h.frames(2); + assert!((back_z(&h) - want).abs() < 1e-3, "looking from z {want}: back {:?}", h.app.cam.back()); + } + // Programmatic look-at (as on opening a sketch) keeps the sketch's own side. + crate::dialogs::look_at_sketch(&mut h.app); + h.frames(2); + crate::dialogs::look_at_sketch(&mut h.app); + h.frames(2); + assert!((back_z(&h) - 1.0).abs() < 1e-3, "{:?}", h.app.cam.back()); + } } diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index aa81e55a..cb897822 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -575,7 +575,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.start(id); } if look { - crate::dialogs::look_at_sketch(app); + crate::dialogs::look_at_sketch_again(app); } if finish { app.finish_sketch(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 99309b3f..5ee09c0d 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -3348,7 +3348,8 @@ pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 { } /// Navigation bar (bottom centre). -/// Turn the view to face the selected planar face or plane, else the active sketch. +/// Turn the view to face the selected planar face or plane, else the active sketch; asked again +/// while it already faces it, from the other side (#1886). pub fn look_at_selection(app: &mut SolveApp) { let target = app.session.selection.iter().find_map(|s| match s { Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)), @@ -3357,11 +3358,11 @@ pub fn look_at_selection(app: &mut SolveApp) { }); match target { Some((p, n)) => { - let mut to = app.cam.looking_from(n); + let mut to = app.cam.looking_from(crate::dialogs::facing(app, n, true)); to.target = p; app.animate_to(to); } - None => crate::dialogs::look_at_sketch(app), + None => crate::dialogs::look_at_sketch_again(app), } } From 9a4d936b7b3414ecb3df11cb8d528fce3008e4be Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:12 +0200 Subject: [PATCH 099/149] fix: text cut into a large part stays on the exact 2D route (#1903) Thanks @NOWN-DEV. --- crates/doc/src/eval.rs | 12 ++++++-- crates/engine/src/cmd/features_more_tests.rs | 26 ++++++++++++++++ crates/kernel/src/prism.rs | 9 ++++++ crates/kernel/src/prism_curve.rs | 31 +++++++++++++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index db6bce95..985aec5e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1754,7 +1754,9 @@ fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[Strin let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); let margin = (size * 1e-4).max(1e-4); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); let want = op == Operation::Join; let mut holes = Vec::with_capacity(r.holes.len()); for h in &r.holes { @@ -1816,7 +1818,9 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target _ => None, } }; - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); let mut grow = vec![0.0; segs.len()]; let mut any = false; for (i, seg) in segs.iter().enumerate() { @@ -1912,7 +1916,9 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); let samples = region_samples(r); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); // Heights of the targets' faces parallel to the profile plane. let tol = (size * 1e-7).max(1e-9); let levels: Vec = meshes diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index edcb2a6c..f21bd390 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1251,3 +1251,29 @@ fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() { let again = cut(&mut s, 2.0); assert!(rel(again, first) < 1e-3, "{first} {again}"); } + +/// Text cut through a large plate (2 m) is decided in 2D in one exact boolean, as it is in a +/// small one. The 2D route's tolerances grow with the part: a font's curve that is merely near a +/// circle was read as an arc ending a micron off the next curve, so the letter's outline did not +/// close, and where the curves of a 3 come close the arrangement left a sliver that closed up to +/// nothing. The cut went to the B-rep boolean instead, which took tens of seconds and moved the +/// tool to find a result at all. +#[test] +fn text_cut_through_a_large_plate_is_one_exact_boolean() { + let taken = |w: f64, h: f64, at: [f64; 2]| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":w,"width":h,"height":1.5,"corner":[0,0,0]})); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane":"XY","offset":1.5})); + run(&mut s, "sketch.text", json!({"text":"PART-23","at":at,"height":7})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "solid.extrude", json!({"distance":-2,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{w} x {h}: {r:?}"); + before - volume(&mut s) + }; + let (small, large) = (taken(200.0, 120.0, [60.0, 40.0]), taken(2000.0, 1200.0, [860.0, 125.0])); + // The letters right through the plate, their counters left as pieces of their own: the same + // material in either plate (measured on meshes, which are coarser on the larger one). + assert!(small > 10.0 && rel(large, small) < 5e-2, "{small} {large}"); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 71d0475a..41f989b7 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1252,6 +1252,15 @@ fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section { let mut loop_tags = Vec::new(); for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near); + // A sliver the arrangement found where a curve's arcs nearly touch another part of + // the outline (at the waist of a 3) closes up to nothing on the exact curves: it + // encloses nothing and would make edges with no length, so it is left out. + if segs.iter().map(Seg2::length).sum::() <= near { + if l == 0 { + break; + } + continue; + } loops.push(Loop2 { segs }); loop_tags.push(ts); } diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index 27ec3b76..e4e03dd2 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -5,6 +5,9 @@ use solidcraft_geom::{Plane, Seg2, Vec2}; use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve}; const MAX_DEGREE: usize = 32; +/// How far an arc read off a curve may end from the curve's end (mm): half the gap a profile +/// loop may have between segments and still close (`crate::build`). +const ARC_END_TOL: f64 = 5e-7; const MAX_CONTROLS: usize = 4096; type H = [f64; 3]; @@ -353,7 +356,10 @@ fn arc(c: &mt::Curve, spans: &[Vec], plane: &Plane, a: Vec2, b: Vec2, tol: f6 } let start = (a - center).angle(); let end = center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius; - if end.dist(b) + error > tol { + // The arc ends where the curve does, as closely as a profile loop must close: within `tol` + // only (which grows with the model), a curve that is merely near a circle (a font's) would + // come back as an arc ending off the next segment's start, and the loop would not close. + if end.dist(b) + error > tol.min(ARC_END_TOL) { return None; } let closed = a.dist(b) < tol; @@ -471,6 +477,29 @@ mod tests { assert!(segment(&arc, &Plane::XY, 1e-5, &mut 0).is_none()); } + /// A curve read as an arc ends where the curve does, however large the tolerance (it grows + /// with the part): a font's quadratic that is within a micron of a circle, not on one, is + /// kept as the curve it is. Read as an arc whose end is a micron off, the outline of a letter + /// cut into a 2 m plate did not close. + #[test] + fn a_curve_near_a_circle_is_not_an_arc_ending_off_it() { + // A parabola within a micron of the circle through its ends and middle. + let e = edge(&[0., 2., 0.], &[0., 0., 0.2], &[1.], 2); + let (a, b) = (Vec2::new(0., 0.), Vec2::new(2., 0.2)); + assert!(from_p3(e.back().point()).dist(solidcraft_geom::Vec3::new(2., 0.2, 0.)) < 1e-12); + for tol in [1e-5, 2e-3] { + let segs = segment(&e, &Plane::XY, tol, &mut 2_000_000).unwrap(); + let (first, last) = (segs.first().unwrap(), segs.last().unwrap()); + assert!(first.start().dist(a) < 1e-9 && last.end().dist(b) < 1e-9, "{tol}: {segs:?}"); + } + // A true arc is still one at either tolerance. + let (p, q) = (mt::Vertex::new(mt::Point3::new(10., 0., 0.)), mt::Vertex::new(mt::Point3::new(0., 10., 0.))); + let arc = builder::circle_arc(&p, &q, mt::Point3::new(10. / 2f64.sqrt(), 10. / 2f64.sqrt(), 0.)); + for tol in [1e-5, 2e-3] { + assert!(matches!(segment(&arc, &Plane::XY, tol, &mut 2_000_000).unwrap()[..], [Seg2::Arc { .. }])); + } + } + #[test] fn discontinuous_and_multiple_winding_curves_decline() { let edge_from = |knots: Vec, points: Vec| { From 9ce940b0b1fac82c4a5139106cbac611aa20c6e4 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:18 +0200 Subject: [PATCH 100/149] Evaluation: sketch and rename commands no longer slow down with the number of placed bodies (#1898) Thanks @NOWN-DEV. --- crates/doc/src/eval.rs | 108 ++++++++++++++++++-- crates/doc/src/eval_cost_tests.rs | 161 ++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 crates/doc/src/eval_cost_tests.rs diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 985aec5e..fee89dd2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -747,6 +747,8 @@ pub struct Model { pub cache: Arc>, /// Evaluate everything from scratch (no reuse; tests compare the two). pub no_cache: bool, + /// The imported texts' digests the last `evaluate` fingerprinted the features with. + step_digests: StepDigests, } /// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a @@ -762,12 +764,12 @@ fn param_names(f: &Feature) -> Vec { names } -fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { +fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool, steps: &StepDigests) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); prev.hash(&mut h); rolled_back.hash(&mut h); vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h); - feature_json(f).hash(&mut h); + feature_json(f, steps).hash(&mut h); for n in param_names(f) { n.hash(&mut h); match vals.get(&n) { @@ -782,6 +784,44 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } +/// The digests of the Import features' STEP texts, by feature id, with the text each was taken +/// from. Every evaluation fingerprints every feature, and writing out a design's imported files +/// each time made every command as slow as the files are large: a text is shared (`Arc`), so +/// its digest is taken once and kept while the feature holds that same text. +#[derive(Clone, Debug, Default)] +struct StepDigests(std::collections::HashMap, u64)>); + +impl StepDigests { + /// The digests of `doc`'s imported texts, reusing `known` for texts it has seen. + fn of(doc: &Document, known: &StepDigests) -> StepDigests { + let texts = doc.features.iter().filter_map(|f| match &f.kind { + FeatureKind::Import { step, .. } => Some((f.id, step)), + _ => None, + }); + StepDigests( + texts + .map(|(id, step)| { + let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step)); + (id, (step.clone(), digest)) + }) + .collect(), + ) + } + + /// The digest of `step`, when it is the text feature `id` was last seen with. + fn get(&self, id: u64, step: &Arc) -> Option { + self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest) + } +} + +fn step_text_digest(text: &str) -> u64 { + #[cfg(test)] + cost_tests::count(|c| c.digested += 1); + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + h.finish() +} + /// Positions and radii are fingerprinted to this quantum (mm): far below anything a design /// can show, far above the solver's rounding, so a sketch solved again to the same geometry /// reads as the same input. @@ -806,8 +846,17 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) { } } -/// A feature as fingerprinted: its definition, a sketch's stored solve quantized. -fn feature_json(f: &Feature) -> String { +/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported +/// file's text by its digest. +fn feature_json(f: &Feature, steps: &StepDigests) -> String { + if let FeatureKind::Import { step, .. } = &f.kind { + let mut g = f.clone(); + if let FeatureKind::Import { step: text, .. } = &mut g.kind { + let digest = steps.get(f.id, step).unwrap_or_else(|| step_text_digest(step)); + *text = Arc::from(format!("{digest:016x}:{}", step.len())); + } + return serde_json::to_string(&g).unwrap_or_default(); + } if let FeatureKind::Sketch { .. } = &f.kind { let mut g = f.clone(); if let FeatureKind::Sketch { sketch, .. } = &mut g.kind { @@ -866,8 +915,8 @@ fn evaluation_values(doc: &Document) -> (BTreeMap, BTreeMap, f: &Feature, prev: u64, rolled_back: bool) -> u64 { - let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f); +fn chain_fingerprint(doc: &Document, vals: &BTreeMap, f: &Feature, prev: u64, rolled_back: bool, steps: &StepDigests) -> u64 { + let mut fp = fingerprint(prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f); // Sheet metal features also depend on the rules (and the parameters they use). if f.kind.is_sheet() { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -906,7 +955,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { for f in doc.features.iter().filter(|f| { matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }) }) { - fingerprint(0, f, vals, false).hash(&mut h); + fingerprint(0, f, vals, false, &StepDigests::default()).hash(&mut h); } doc.units.hash(&mut h); serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); @@ -1089,10 +1138,11 @@ impl Model { } // The fingerprints from the first stored sketch on. let vals = evaluation_values(doc).0; + let steps = StepDigests::of(doc, &self.step_digests); let marker = doc.marker.unwrap_or(usize::MAX); let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint); for (i, f) in doc.features.iter().enumerate().skip(first) { - prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker); + prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker, &steps); if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) { r.fingerprint = prev_fp; } @@ -1120,6 +1170,7 @@ impl Model { let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); + self.step_digests = StepDigests::of(doc, &self.step_digests); // Each feature's component, for `Document::legacy_body_component` on every body after // every feature without searching the timeline for each (the first feature with an id). let mut feature_component: std::collections::HashMap = std::collections::HashMap::with_capacity(doc.features.len()); @@ -1131,7 +1182,7 @@ impl Model { let rolled_back = i >= marker; // Opening was cancelled: the rest is skipped and the result discarded. let cancelled = watched.step(i); - let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back); + let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests); prev_fp = fp; if reuse && let Some(old) = self.results.get(i) @@ -1162,7 +1213,7 @@ impl Model { continue; } // The same feature on the same inputs: its earlier result. - let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state); + let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f), ctx, &state); if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { @@ -4293,6 +4344,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let anchor = crate::face_anchor::plane_face_anchor(plane); let fst = match anchor { Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?, + // A sketch that reads no geometry (a fixed plane, no projections) needs no other + // component's bodies moved into its frame: placing every body of a large + // assembly again is what made each sketch edit as slow as the design is big. + None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st), None => state_in_frame(doc, st, f.component), }; let (plane, face_warning) = match (anchor, plane) { @@ -4456,6 +4511,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?; doc.resolve_attached_plane(vals, plane, f.component, st, &fst)? } + // A plane of numbers alone reads no body: placing them all is the design's size. + None if plane_is_fixed(plane) => doc.resolve_plane_in(vals, plane, 0, Some(st))?, // Picks on bodies of other (moved) components are where those bodies are shown. None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?, }; @@ -5038,6 +5095,31 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: state_in_frame(doc, st, comp) } +/// Whether `plane` is given by the origin planes and numbers alone. +fn plane_is_fixed(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base), + _ => false, + } +} + +/// Whether resolving `plane` reads the model's bodies or sketches (a face, edges, points), +/// rather than only the origin planes, numbers and construction planes. +fn plane_reads_geometry(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base), + PlaneRef::Midplane { .. } + | PlaneRef::TwoEdges { .. } + | PlaneRef::ThreePoints { .. } + | PlaneRef::Tangent { .. } + | PlaneRef::AlongPath { .. } + | PlaneRef::Perpendicular { .. } + | PlaneRef::Face { .. } => true, + } +} + pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { state_in_frame_at(doc, st, comp, doc.component_transform(comp)) } @@ -5114,6 +5196,8 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram if !crate::is_identity(m) && let Ok(t) = kernel::transform_matrix(&b.body, *m) { + #[cfg(test)] + cost_tests::count(|c| c.moved += 1); // Moving a body keeps its faces (and their order): it keeps their names. let names = crate::naming::NameCell::same(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); @@ -5409,6 +5493,10 @@ mod chain_tests { } } +#[cfg(test)] +#[path = "eval_cost_tests.rs"] +mod cost_tests; + #[cfg(test)] mod body_name_tests { use super::*; diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs new file mode 100644 index 00000000..a598ea88 --- /dev/null +++ b/crates/doc/src/eval_cost_tests.rs @@ -0,0 +1,161 @@ +//! What an evaluation costs after an edit at the end of a large design: the features before +//! the edit are reused, so the edit's cost must not grow with the bodies and imported files +//! the design already holds. A sketch on a fixed plane once moved every body of every placed +//! component into its frame, and every evaluation wrote every imported STEP file out again to +//! fingerprint it, so each sketch command took longer the more parts were inserted. + +use std::cell::Cell; +use std::sync::Arc; + +use solidcraft_geom::{Plane, Vec2, Vec3}; +use solidcraft_kernel as kernel; +use solidcraft_sketch::Sketch; + +use crate::*; + +/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Counts { + pub moved: usize, + pub digested: usize, +} + +thread_local! { + static COUNTS: Cell = const { Cell::new(Counts { moved: 0, digested: 0 }) }; +} + +pub(super) fn count(f: impl FnOnce(&mut Counts)) { + COUNTS.with(|c| { + let mut n = c.get(); + f(&mut n); + c.set(n); + }); +} + +/// The work `f` does on this thread. +fn counted(f: impl FnOnce()) -> Counts { + COUNTS.with(|c| c.set(Counts::default())); + f(); + COUNTS.with(Cell::get) +} + +/// `parts` imported files of two boxes each, every one in its own component placed away from +/// the origin (as inserted parts are). +fn placed_imports(parts: usize) -> Document { + let mut doc = Document::new("placed imports"); + for i in 0..parts { + let x = 30.0 * i as f64; + let a = kernel::box_solid(Vec3::new(x, 0.0, 0.0), Vec3::new(x + 10.0, 10.0, 10.0)).unwrap(); + let b = kernel::box_solid(Vec3::new(x, 20.0, 0.0), Vec3::new(x + 5.0, 25.0, 5.0)).unwrap(); + let step: Arc = kernel::step_export(&[&a, &b], "test").unwrap().into(); + let comp = doc.add_component(Some(&format!("Part {i}")), 0).unwrap(); + let o = doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap(); + o.transform = rigid(Vec3::new(0.0, 0.0, 5.0 * i as f64), Vec3::ZERO, Vec3::Z, 0.05 * i as f64); + let kind = FeatureKind::Import { file: format!("part{i}.step"), step, components: Vec::new(), skip: Vec::new() }; + let id = doc.add_feature(kind, Some(&format!("Part {i}"))).unwrap(); + doc.feature_mut(id).unwrap().component = comp; + } + doc +} + +fn line_sketch(plane: PlaneRef, to: Vec2) -> FeatureKind { + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(to, None).unwrap(); + sk.add_line_pts(a, b, None).unwrap(); + FeatureKind::Sketch { plane, sketch: sk } +} + +#[test] +fn a_sketch_on_a_fixed_plane_costs_the_same_in_a_large_placed_design() { + let parts = 12; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert_eq!(model.state().bodies.len(), parts * 2); + + // A construction plane, then sketches on the origin planes, on an offset of one and on the + // construction plane, as commands make them one after another: no body is moved and no + // file digested again. + let plane = doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Origin { name: "XZ".into() } }, Some("Datum")).unwrap(); + assert_eq!(counted(|| model.evaluate(&doc)), Counts::default(), "construction plane"); + assert!(model.result(plane).unwrap().error.is_none()); + let datum = doc.feature(plane).unwrap().name.clone(); + let planes = [ + PlaneRef::Origin { name: "XY".into() }, + PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "YZ".into() }), distance: "5 mm".into() }, + PlaneRef::Construction { name: datum.into() }, + ]; + for (k, plane) in planes.into_iter().enumerate() { + let id = doc.add_feature(line_sketch(plane, Vec2::new(10.0, 1.0 + k as f64)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k}"); + assert_eq!(model.last_recomputed, 1); + let r = model.result(id).unwrap(); + assert!(r.error.is_none(), "{:?}", r.error); + assert_eq!(model.state().bodies.len(), parts * 2); + // Editing the sketch again recomputes it alone, for the same cost. + if let Ok(sk) = doc.sketch_mut(id) + && let Some(p) = sk.points.get_mut(1) + { + p.pos = Vec2::new(12.0, 2.0); + } + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k} edited"); + assert_eq!(model.last_recomputed, 1); + } +} + +#[test] +fn a_sketch_on_a_face_still_sees_the_placed_bodies() { + let parts = 3; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + // A face pick reads the model as the sketch's component sees it: the other components' + // bodies are moved into its frame, as before. + let top = Plane::new(Vec3::new(0.0, 0.0, 10.0), Vec3::X, Vec3::Y).unwrap(); + let face = PlaneRef::Face { plane: top, at: Vec3::new(5.0, 5.0, 10.0), name: None, anchor: None }; + doc.add_feature(line_sketch(face, Vec2::new(3.0, 3.0)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert!(work.moved >= parts * 2 - 2, "{work:?}"); + assert_eq!(work.digested, 0); +} + +#[test] +fn an_imported_file_is_fingerprinted_by_its_text() { + let doc = placed_imports(2); + let mut model = Model::new(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work.digested, 2); + let fingerprints = |m: &Model| m.results.iter().map(|r| r.fingerprint).collect::>(); + let before = fingerprints(&model); + + // The same text held by another copy (an undo state, a design opened again): the same + // fingerprints, and nothing evaluated again. + let mut copy = doc.clone(); + for f in &mut copy.features { + if let FeatureKind::Import { step, .. } = &mut f.kind { + *step = Arc::from(step.to_string()); + } + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 2); + assert_eq!(fingerprints(&model), before); + assert_eq!(model.last_recomputed, 0); + + // Other text: that feature and the ones after it are evaluated again. + let other = kernel::box_solid(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap(); + let text: Arc = kernel::step_export(&[&other], "test").unwrap().into(); + if let Some(FeatureKind::Import { step, .. }) = copy.features.last_mut().map(|f| &mut f.kind) { + *step = text; + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 1); + let after = fingerprints(&model); + assert_eq!(after.first(), before.first()); + assert_ne!(after.last(), before.last()); + assert_eq!(model.last_recomputed, 1); + assert_eq!(model.state().bodies.len(), 3); +} From 2831426c51c1ef32854d8974206d0f44c18e3554 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:22 +0200 Subject: [PATCH 101/149] solid.mirror: mirror bodies of placed components where they are shown (#1896) Thanks @NOWN-DEV. --- crates/doc/src/assembly.rs | 15 +++ crates/doc/src/document.rs | 5 + crates/doc/src/eval.rs | 35 +++++- .../src/format/pending/mirror-placements.rs | 5 + crates/engine/src/cmd/features.rs | 22 ++++ crates/engine/src/frames.rs | 8 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/mirror_placed.rs | 105 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 10 +- 9 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 crates/doc/src/format/pending/mirror-placements.rs create mode 100644 crates/engine/src/tests/mirror_placed.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index a52580e3..c09a7725 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -244,6 +244,21 @@ impl Document { m } + /// World transform of one placement of `component`: the occurrence `path` from the root + /// (empty for the root itself). `None` when the path no longer leads to that component. + pub fn path_transform(&self, component: u64, path: &[u64]) -> Option { + if path.len() > MAX_PLACEMENT_DEPTH + 1 { + return None; + } + let (mut m, mut c) = (IDENTITY, 0); + for id in path { + let o = self.occurrences.iter().find(|o| o.id == *id && o.parent == c)?; + m = mat_mul(&m, &o.transform); + c = o.component; + } + (c == component).then_some(m) + } + /// Every placement of each component: (component, occurrence path ids, world transform). /// A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`] placements. pub fn placements(&self) -> Vec<(u64, Vec, Mat)> { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 7e43f219..3a88a021 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -682,6 +682,11 @@ pub enum FeatureKind { /// Join each mirrored body with its original. #[serde(default, skip_serializing_if = "std::ops::Not::not")] combine: bool, + /// Where each of `bodies` is mirrored from: the occurrence path of the placement it was + /// picked at. The body is mirrored where that placement shows it, as the feature's own + /// component sees it. Empty in older designs, which mirror the bodies' own geometry. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + placements: Vec>, }, /// A drilled hole (simple, counterbore or countersink) at a point, into the material. Hole { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fee89dd2..e626f98b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1039,7 +1039,9 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { FeatureKind::Revolve { to, .. } => to.is_some(), _ => false, }; - if to_object { + // A mirror of placed bodies follows where they are placed. + let placed_mirror = matches!(&f.kind, FeatureKind::Mirror { placements, .. } if !placements.is_empty()); + if to_object || placed_mirror { return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) }; } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; @@ -3741,6 +3743,20 @@ fn mirror_matrix(pl: &Plane) -> Mat { const MAX_INSTANCES: usize = 1000; +/// Where a mirrored body is, in the frame of the mirror's component: through the placement it +/// was picked at and the feature's component frame. `None` when that is the body's own frame +/// (and in older designs, which saved no placement). +fn mirrored_placement(doc: &Document, f: &Feature, mb: &ModelBody, path: Option<&[u64]>) -> Result> { + let Some(path) = path else { return Ok(None) }; + let owner = doc.model_body_component(mb); + let lost = || DocError::Invalid(format!("the placement `{}` was mirrored at no longer exists", mb.name)); + let world = doc.path_transform(owner, path).ok_or_else(lost)?; + let world = doc.model_body_offset(mb).map(|offset| crate::mat_mul(&world, offset)).unwrap_or(world); + let inverse = crate::mat_inverse(&doc.component_transform(f.component)).ok_or_else(lost)?; + let rel = crate::mat_mul(&inverse, &world); + Ok((!crate::is_identity(&rel)).then_some(rel)) +} + /// The placements (column-major matrices) of a pattern's copies, the original excluded. pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result> { let (vals, _) = doc.param_values(); @@ -4670,17 +4686,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } - FeatureKind::Mirror { features, plane, bodies, combine } => { + FeatureKind::Mirror { features, plane, bodies, combine, placements } => { let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; if bodies.is_empty() { return replay(doc, vals, f, st, &crate::plain(features), &[mirror_matrix(&pl)], warning); } let m = mirror_matrix(&pl); let mut output = 0; - for n in bodies { + for (k, n) in bodies.iter().enumerate() { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; - let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1); + // A body of a component placed elsewhere is mirrored where it is shown: the copy + // (in this feature's component) from its placement, a joined body in its own frame. + let placed = mirrored_placement(doc, f, &mb, placements.get(k).map(Vec::as_slice))?; + let (copy, own) = match placed { + Some(rel) => { + let inverse = crate::mat_inverse(&rel).ok_or_else(|| DocError::Invalid(format!("the placement of `{n}` is singular")))?; + let copy = crate::mat_mul(&m, &rel); + (copy, crate::mat_mul(&inverse, ©)) + } + None => (m, m), + }; + let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, if *combine { own } else { copy })?, 1); if *combine { let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)? .ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?; diff --git a/crates/doc/src/format/pending/mirror-placements.rs b/crates/doc/src/format/pending/mirror-placements.rs new file mode 100644 index 00000000..36432dd6 --- /dev/null +++ b/crates/doc/src/format/pending/mirror-placements.rs @@ -0,0 +1,5 @@ +//! A Mirror of bodies keeps the placement each body was picked at (`placements`: one occurrence +//! path per body), and mirrors the body where that placement shows it, so a body of a moved or +//! inserted component, or of one of its further instances, is mirrored where it is seen. Older +//! designs need no transformation: a Mirror without `placements` mirrors the bodies' own +//! geometry as before; the last release asks for an update instead of mirroring them there. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index c614a0f9..20becc82 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1358,6 +1358,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result { let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let plane = plane_param(s, p.get("plane"), cmd)?; let combine = bool_(p, "combine").unwrap_or(false); + let (bodies, placements) = placed_bodies(s, bodies, cmd)?; add_feature( s, p, @@ -1366,10 +1367,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result { plane, bodies: bodies.into_iter().map(BodyName).collect(), combine, + placements, }, ) } +/// Bodies picked where the viewport shows them: each world name ("Body1", or "Body1 (Occ:2)" +/// for a further instance) becomes its component's body name, with the occurrence path of the +/// placement it was picked at. Names stay as they are (and no placement is kept) while every +/// component is in place, or when one of them is not a body. +fn placed_bodies(s: &Session, names: Vec, cmd: &str) -> Result<(Vec, Vec>)> { + if names.is_empty() || !crate::frames::placed(s) { + return Ok((names, Vec::new())); + } + let frames = s.doc.body_frames_with_source(&s.model.state()); + let mut picked = Vec::with_capacity(names.len()); + for name in &names { + let Some((_, own, _, path, _)) = frames.iter().find(|(world, ..)| world == name) else { return Ok((names, Vec::new())) }; + if path.iter().any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, format!("`{name}` has a position that is not captured yet: Capture Position (component.capture_position) first"))); + } + picked.push((own.clone(), path.clone())); + } + Ok(picked.into_iter().unzip()) +} + /// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one. pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> { match name.trim().to_ascii_uppercase().as_str() { diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 10fed494..122e1626 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -36,7 +36,8 @@ enum K { /// component frame, whichever component the geometry under it belongs to (a sketch in the /// root on a moved component's face finds the face where it is shown). Here, - /// A plane object for a new sketch or canvas: into the active component's frame. + /// A plane object for a new sketch or canvas (or a mirror's copies): into the active + /// component's frame. HerePlane, } @@ -59,7 +60,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "solid.press_pull" => &[("face", Pick), ("edges", Picks)], "solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], "solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)], - "solid.mirror" | "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], + // A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`); + // its plane is in the active component, where the copies go. + "solid.mirror" => &[("plane", HerePlane)], + "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], "plastic.boss" | "plastic.snap_fit" | "plastic.rest" => { &[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index db68167a..98f0b986 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -314,6 +314,7 @@ mod large_step_import; mod loft_cut; mod loft_ring; mod loft_spacing; +mod mirror_placed; mod mirror_planes; #[cfg(not(target_arch = "wasm32"))] mod native_cad_import; diff --git a/crates/engine/src/tests/mirror_placed.rs b/crates/engine/src/tests/mirror_placed.rs new file mode 100644 index 00000000..781fca35 --- /dev/null +++ b/crates/engine/src/tests/mirror_placed.rs @@ -0,0 +1,105 @@ +//! `solid.mirror` of bodies in a placed component (a moved occurrence, a further instance, an +//! inserted design) mirrors each body where it is shown. Before, it mirrored the component's own +//! geometry and put the copy in the active component, so the copy lost the placement. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::run; +use crate::*; + +fn world_box(s: &Session, body: &str) -> (Vec3, Vec3) { + let b = s.world_state().body(body).unwrap_or_else(|| panic!("no body `{body}`")).mesh().bounds(); + (b.min, b.max) +} + +/// The box of `body` mirrored in the world YZ plane. +fn mirrored_yz(s: &Session, body: &str) -> (Vec3, Vec3) { + let (lo, hi) = world_box(s, body); + (Vec3::new(-hi.x, lo.y, lo.z), Vec3::new(-lo.x, hi.y, hi.z)) +} + +fn assert_box(got: (Vec3, Vec3), want: (Vec3, Vec3)) { + assert!(got.0.dist(want.0) < 1e-6 && got.1.dist(want.1) < 1e-6, "got {got:?}, want {want:?}"); +} + +/// A component "Arm" holding "ArmBody", its occurrence turned and moved; the root is active. +fn moved_arm(s: &mut Session) -> (u64, u64) { + let c = run(s, "component.create", json!({"name": "Arm"})); + let (component, occurrence) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); + run(s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 4, "height": 6, "body_name": "ArmBody"})); + run(s, "component.activate", json!({"component": 0})); + run(s, "occurrence.move", json!({"occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg", "translate": [30, 50, 0]})); + (component, occurrence) +} + +fn created(r: &Value) -> String { + r["created_bodies"][0].as_str().unwrap().to_string() +} + +#[test] +fn a_moved_components_body_is_mirrored_where_it_is_shown() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}))); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // The copy follows its source when the occurrence moves again. + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "space": "world", "translate": [0, 200, 0]})); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // It survives a save and reopen. + let t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_box(world_box(&t, ©), mirrored_yz(&t, "ArmBody")); +} + +#[test] +fn a_further_instance_is_mirrored_at_its_own_placement() { + let mut s = Session::default(); + let (component, _) = moved_arm(&mut s); + run(&mut s, "occurrence.copy", json!({"component": component, "translate": [0, 0, 40]})); + let names: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + let instance = names.iter().find(|n| n.starts_with("ArmBody (")).cloned().unwrap(); + let r = run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody", instance], "plane": "YZ"})); + let copies: Vec = r["created_bodies"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect(); + assert_eq!(copies.len(), 2, "{r}"); + assert_box(world_box(&s, &copies[0]), mirrored_yz(&s, "ArmBody")); + assert_box(world_box(&s, &copies[1]), mirrored_yz(&s, &instance)); +} + +#[test] +fn a_plane_picked_on_a_moved_body_is_a_plane_of_the_active_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane x = 30 through the moved body: the copy is mirrored in it, as shown. + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [30, 50, 0], "normal": [1, 0, 0]}}))); + let (lo, hi) = world_box(&s, "ArmBody"); + assert_box(world_box(&s, ©), (Vec3::new(60.0 - hi.x, lo.y, lo.z), Vec3::new(60.0 - lo.x, hi.y, hi.z))); +} + +#[test] +fn a_joined_mirror_of_a_moved_body_stays_in_its_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane through the body's far end: the joined body doubles along x where it is. + let (lo, hi) = world_box(&s, "ArmBody"); + run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [hi.x, 0, 0], "normal": [1, 0, 0]}, "combine": true})); + let len = hi.x - lo.x; + assert_box(world_box(&s, "ArmBody"), (lo, Vec3::new(hi.x + len, hi.y, hi.z))); +} + +#[test] +fn bodies_in_place_keep_no_placement() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); + run(&mut s, "solid.mirror", json!({"bodies": ["Base"], "plane": "YZ"})); + let f = s.doc.features.last().unwrap(); + assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Mirror { placements, .. } if placements.is_empty()), "{:?}", f.kind); +} + +#[test] +fn a_position_not_captured_yet_is_refused() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [5, 0, 0], "capture": false})); + let e = s.execute("solid.mirror", &json!({"bodies": ["ArmBody"], "plane": "YZ"})).unwrap_err().to_string(); + assert!(e.contains("Capture Position"), "{e}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ff9703e2..b4ef0387 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4921,14 +4921,20 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Mirror { features, plane, bodies, placements, .. } => { let mut d = start("solid.mirror")?; let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); // Replay references stay features, including features that created a body. let as_features = !features.is_empty(); if let Some(inp) = d.inputs.get_mut(0) { if !bodies.is_empty() { - inp.items = bodies.iter().map(|name| Sel::Body { name: name.to_string() }).collect(); + // Each body as the placement it was mirrored at shows it (an instance by its world name). + let own = s.model.state(); + let world = |k: usize, name: &str| match (placements.get(k), own.body(name)) { + (Some(path), Some(b)) => s.doc.world_body_name(&own, name, s.doc.model_body_component(b), path), + _ => name.to_string(), + }; + inp.items = bodies.iter().enumerate().map(|(k, name)| Sel::Body { name: world(k, name) }).collect(); } else if as_features { inp.accept = selection::FEATURES; inp.label = objects_label(inp.accept); From 713111d9df9bd1f6f368b45481a99d09d2d36f01 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:26 +0200 Subject: [PATCH 102/149] STEP export: an inserted assembly's nested parts are placed once (#1895) Thanks @NOWN-DEV. --- crates/engine/src/tests/import_component.rs | 38 +++++++++++++++++++++ crates/io/src/lib.rs | 18 +++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/import_component.rs b/crates/engine/src/tests/import_component.rs index 061f40d9..6fb58874 100644 --- a/crates/engine/src/tests/import_component.rs +++ b/crates/engine/src/tests/import_component.rs @@ -322,3 +322,41 @@ fn a_design_with_a_loose_import_rebuilds_unchanged() { assert_eq!(shown_boxes(&s), file_boxes(&path)); let _ = std::fs::remove_dir_all(&dir); } + +/// A design whose `Outer` component holds a plate and an `Inner` component placed twice (the +/// first placement turned and moved), with `Outer` itself turned about X and lifted. +fn nested_file(dir: &std::path::Path) -> String { + let mut s = Session::default(); + let outer = run(&mut s, "component.create", json!({"name": "Outer"}))["occurrence"].clone(); + run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "body_name": "Plate"})); + let inner = run(&mut s, "component.create", json!({"name": "Inner"}))["occurrence"].clone(); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10, "body_name": "Block"})); + run(&mut s, "component.activate", json!({"component": "Outer"})); + run(&mut s, "occurrence.move", json!({"occurrence": inner, "axis": [0, 0, 1], "angle": "90 deg", "translate": [100, 0, 0]})); + run(&mut s, "occurrence.copy", json!({"component": "Inner", "translate": [0, 0, 30]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": outer, "space": "world", "axis": [1, 0, 0], "angle": "90 deg", "translate": [0, 0, 50]})); + let path = dir.join("nested.step").to_string_lossy().to_string(); + run(&mut s, "file.export", json!({"path": path})); + path +} + +#[test] +fn an_inserted_assembly_exports_its_nested_parts_where_they_are() { + let dir = temp_dir("reexport"); + let path = nested_file(&dir); + let placed = file_boxes(&path); + assert_eq!(placed.len(), 3, "a plate and two blocks: {placed:?}"); + let mut s = Session::default(); + run(&mut s, "file.insert_step", json!({"path": path})); + assert_eq!(shown_boxes(&s), placed); + // The whole design as an assembly: every part is placed once, by its occurrences. + let again = dir.join("again.step").to_string_lossy().to_string(); + run(&mut s, "file.export", json!({"path": again})); + assert_eq!(file_boxes(&again), placed); + // And inserted again, it shows as the first file did. + let mut back = Session::default(); + run(&mut back, "file.insert_step", json!({"path": again})); + assert_eq!(shown_boxes(&back), placed); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b80108a0..25ed46ac 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -488,7 +488,9 @@ fn mesh_object(b: &solidcraft_doc::ModelBody, tagged: bool) -> Result Result> { use solidcraft_kernel::{ExportBody, ExportProduct}; let mut index: std::collections::HashMap = std::collections::HashMap::new(); @@ -498,10 +500,18 @@ pub fn step_assembly(doc: &Document, local: &ModelState, name: &str) -> Result> = local + .bodies + .iter() + .map(|b| match doc.model_body_offset(b) { + Some(offset) if !solidcraft_doc::is_identity(offset) => solidcraft_kernel::transform_matrix(&b.body, *offset).map(Some), + _ => Ok(None), + }) + .collect::>()?; + for (b, framed) in local.bodies.iter().zip(&framed) { + let comp = doc.model_body_component(b); let Some(p) = index.get(&comp).and_then(|i| products.get_mut(*i)) else { continue }; - p.bodies.push(ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }); + p.bodies.push(ExportBody { name: b.name.clone(), body: framed.as_ref().unwrap_or(&b.body), color: b.body.color() }); } for o in &doc.occurrences { let (Some(&parent), Some(&child)) = (index.get(&o.parent), index.get(&o.component)) else { continue }; From 962070e955e69616ff18c8aa19b8adfc46fbc761 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:31 +0200 Subject: [PATCH 103/149] Interference: a pin a hair off the axis of its hole gets its crescent (#1893) Thanks @NOWN-DEV. --- crates/engine/src/cmd/joints.rs | 14 +- crates/engine/src/cmd/joints_tests.rs | 28 ++ crates/kernel/src/coaxial_overlap.rs | 424 ++++++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 + 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 crates/kernel/src/coaxial_overlap.rs diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 6e94d472..09a90321 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -73,7 +73,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"), CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params( - "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", + "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", ), ]; @@ -1156,6 +1156,16 @@ fn interference(s: &mut Session, p: &Value) -> Result { let (mut hits, mut unchecked) = (Vec::new(), Vec::new()); for (i, j) in pairs { let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue }; + let threshold = 1e-9 * a.body.size().max(1.0).powi(3); + // A pin a hair off the axis of its hole: the crescent the walls share, which the + // boolean can't intersect. + if let Some(o) = solidcraft_kernel::coaxial_overlap(&a.body, &b.body) { + if o.volume > threshold { + hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid, + "bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}})); + } + continue; + } // A pair whose shared part can't be worked out is reported, not passed as clear. let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect)); @@ -1178,7 +1188,7 @@ fn interference(s: &mut Session, p: &Value) -> Result { continue; } }; - if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) { + if m.volume > threshold { hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}})); } } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index f0b42b4e..4c5e4a6c 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1452,3 +1452,31 @@ mod cam; // Plots and interference of motion studies, on the four-bar and slider-crank above. #[path = "motion_analysis_tests.rs"] mod motion_analysis; + +/// A pin through a hole of its own radius whose axis is a hair off the hole's (parts placed from +/// rounded coordinates): the walls are all but tangent along two lines, which the boolean can't +/// intersect. Each offset gets a checked answer: clear on the axis and within the kernel's +/// same-surface tolerance, and otherwise the crescent the pin shares with the plate, against +/// the textbook lens of two circles. The pin is chamfered, so it is not a plain prism. +#[test] +fn interference_of_a_pin_a_hair_off_its_hole() { + let crescent = |r: f64, e: f64| std::f64::consts::PI * r * r - (2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt()); + for offset in [0.0, 1e-6, 5.9e-4, 1e-2] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 5, "corner": [-10, -10, 0], "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [offset, 0, -2], "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.chamfer", json!({"edges": [[offset + 3.0, 0, 7]], "distance": 0.5, "body": "Pin"})); + let i = run(&mut s, "inspect.interference", json!({"bodies": ["Plate", "Pin"]})); + assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{offset}: {i}"); + if offset < 1e-5 { + assert_eq!(i["count"], 0, "{offset}: {i}"); + continue; + } + assert_eq!(i["count"], 1, "{offset}: {i}"); + let want = 5.0 * crescent(3.0, offset); + let got = i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0); + assert!((got - want).abs() < 1e-6 * want, "{offset}: {got} against {want}: {i}"); + assert!((i["interferences"][0]["near_coaxial"]["offset_mm"].as_f64().unwrap_or(0.0) - offset).abs() < 1e-9, "{i}"); + } +} diff --git a/crates/kernel/src/coaxial_overlap.rs b/crates/kernel/src/coaxial_overlap.rs new file mode 100644 index 00000000..378a4fbe --- /dev/null +++ b/crates/kernel/src/coaxial_overlap.rs @@ -0,0 +1,424 @@ +//! The shared volume of a pin in a hole of (nearly) its own size whose axis is a hair off the +//! hole's (parts placed from rounded coordinates). +//! +//! The pin's wall and the hole's wall then meet along two lines where they are all but tangent, +//! which the boolean cannot intersect, and the facets of the two walls pass through each other +//! by less than the boolean's sampled shortcuts can see. So the overlap is worked out in two +//! parts instead. The pin's wall is moved off the hole's by a clear step (`offset_body`) and the +//! bodies are intersected as they then are, which the boolean can do: narrowed, that gives the +//! overlap away from the walls (the core). In the band the move swept, what the two walls +//! share is a crescent in every section, whose area follows from the two circles alone. The +//! band is assumed to hold nothing but the two walls; that is checked by intersecting with the +//! pin widened by half the step and by the whole step, whose volumes must come out as the core +//! plus the crescents those wider circles give. Where they don't (a cross hole through the +//! band, a hole that is not round all the way, a pin end inside the hole), the overlap is not +//! answered here and the caller takes the general boolean. + +use solidcraft_geom::{Aabb3, Vec3}; + +use crate::body::Body; +use crate::{BoolOp, FaceSurface}; + +/// The overlap of two bodies with a pin of one in a hole of the other, worked out as above. +#[derive(Clone, Debug)] +pub struct CoaxialOverlap { + pub volume: f64, + /// Approximate: the core's area plus the crescents' walls and ends. + pub area: f64, + pub centroid: Vec3, + pub bbox: Aabb3, + /// The largest offset between a pin's axis and its hole's, and the length they share. + pub offset: f64, + pub length: f64, +} + +/// Largest axis offset plus radius difference handled, as a share of the radius. +const NEAR: f64 = 0.01; +/// The step the pin's wall is moved by, as a share of its radius. +const STEP: f64 = 0.1; + +/// The faces of one body on one cylinder, the side its material is on and their extent along +/// the axis. +#[derive(Clone, Debug)] +struct Wall { + faces: Vec, + origin: Vec3, + axis: Vec3, + radius: f64, + /// Material inside the cylinder (a pin), not outside it (a hole). + boss: bool, + /// Intervals along `axis` from `origin`, one per face. + spans: Vec<(f64, f64)>, +} + +/// A pin of one body (`pin_in_a`) in a hole of the other, and the length they share. +#[derive(Clone, Debug)] +struct Pair { + pin: Wall, + pin_in_a: bool, + hole: Wall, + /// The hole's axis seen from the pin's, across it, and how far. + toward: Vec3, + offset: f64, + /// Where they share length along the pin's axis. + shared: Vec<(f64, f64)>, + step: f64, +} + +/// The overlap of `a` and `b` when a pin of one sits in a hole of the other on nearly the same +/// cylinder (axes parallel, offset plus radius difference within 1 % of the radius, but not on +/// the same surface), worked out as the module says. `None` when there is no such pin, or the +/// bodies hold more than the pin and the hole where the work assumes they don't: the caller +/// then takes the general boolean. +pub fn coaxial_overlap(a: &Body, b: &Body) -> Option { + if a.is_mesh() || b.is_mesh() { + return None; + } + crate::guard("near-coaxial overlap", || Ok(overlap(a, b))).ok().flatten() +} + +fn overlap(a: &Body, b: &Body) -> Option { + let size = a.size().max(b.size()); + if !(size.is_finite() && size > 0.0) { + return None; + } + // The same surface as the kernel takes it elsewhere: walls that close are one wall. + let same = (size * 1e-7).max(1e-9) * 10.0; + // Meshed only when two cylinders are close enough to be asked about. + let (ca, cb) = (cylinders(a), cylinders(b)); + let close = |x: &Wall, y: &Wall| x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && near(x, y, same).is_some(); + if !ca.iter().any(|x| cb.iter().any(|y| close(x, y))) { + return None; + } + let (wa, wb) = (walls(a, ca, same)?, walls(b, cb, same)?); + let mut pairs = Vec::new(); + for x in &wa { + for y in &wb { + if x.axis.dot(y.axis).abs() <= 1.0 - 1e-9 { + continue; + } + let (pin, hole, pin_in_a) = match (x.boss, y.boss) { + (true, false) => (x, y, true), + (false, true) => (y, x, false), + // Two pins or two holes on nearly one cylinder: not this case. + _ if near(x, y, same).is_some() => return None, + _ => continue, + }; + let Some((toward, offset)) = near(pin, hole, same) else { continue }; + let shared = intersect(&union(&pin.spans), &union(&project(hole, pin))); + if total(&shared) <= same { + continue; + } + let step = (pin.radius * STEP).min(size * 0.02); + pairs.push(Pair { pin: pin.clone(), pin_in_a, hole: hole.clone(), toward, offset, shared, step }); + } + } + if pairs.is_empty() { + return None; + } + // A pin's faces move together: one step for each of them (the pin's walls do not repeat + // across pairs unless one pin is near two holes, which the check below then answers). + let shared_at = |k: f64| -> Option<(f64, Option)> { + let moved = |body: &Body, in_a: bool| -> Option { + let mine: Vec<&Pair> = pairs.iter().filter(|p| p.pin_in_a == in_a).collect(); + if mine.is_empty() { + return Some(body.clone()); + } + let shift = |i: usize, _| mine.iter().find(|p| p.pin.faces.contains(&i)).map(|p| p.step * k).unwrap_or(0.0); + let m = crate::offset::offset_body(body, shift).ok()?; + m.validity().is_empty().then_some(m) + }; + let (ma, mb) = (moved(a, true)?, moved(b, false)?); + let (r, report) = crate::capture_boolean_diagnostics(|| crate::ops::boolean(&ma, &mb, BoolOp::Intersect)); + if report.unverified().is_some() || report.warning().is_some() { + return None; + } + match r.ok()? { + None => Some((0.0, None)), + Some(body) => Some((crate::measure(&body).ok()?.volume, Some(body))), + } + }; + // The crescents' volume with every pin grown by `k` steps. + let crescents = + |k: f64| -> f64 { pairs.iter().map(|p| total(&p.shared) * crescent(p.pin.radius + p.step * k, p.hole.radius, p.offset).area).sum() }; + let (core, core_body) = shared_at(-1.0)?; + for k in [0.5, 1.0] { + let (v, _) = shared_at(k)?; + let gain = crescents(k) - crescents(-1.0); + if !((v - core - gain).abs() <= 0.02 * gain + 1e-4 * v.abs() + 1e-12 * size.powi(3)) { + return None; + } + } + // The band held the two walls only: the overlap is the core plus the crescents. + let mut volume = core; + let mut moment = Vec3::ZERO; + let mut area = 0.0; + let mut bbox = Aabb3::EMPTY; + if let Some(body) = &core_body + && let Ok(m) = crate::measure(body) + { + moment = m.centroid * m.volume; + area = m.area; + bbox = m.bbox; + volume = m.volume; + } + for p in &pairs { + let c = crescent(p.pin.radius, p.hole.radius, p.offset); + let length = total(&p.shared); + // Less what the narrowed pin already reached (nothing, unless the hole is the smaller). + let v = length * c.area - length * crescent(p.pin.radius - p.step, p.hole.radius, p.offset).area; + let mid = p.shared.iter().map(|(s, t)| (s + t) * 0.5 * (t - s)).sum::() / length; + let side = p.toward.cross(p.pin.axis); + moment += (p.pin.origin + p.pin.axis * mid + p.toward * c.centroid) * v; + volume += v; + area += p.shared.len() as f64 * 2.0 * c.area + length * c.perimeter; + for (s, t) in &p.shared { + for q in crescent_outline(p.pin.radius, p.hole.radius, p.offset) { + let at = p.pin.origin + p.toward * q.0 + side * q.1; + bbox.add(at + p.pin.axis * *s); + bbox.add(at + p.pin.axis * *t); + } + } + } + let centroid = if volume > 0.0 { moment / volume } else { bbox.center() }; + let offset = pairs.iter().map(|p| p.offset).fold(0.0, f64::max); + let length = pairs.iter().map(|p| total(&p.shared)).sum(); + Some(CoaxialOverlap { volume: volume.max(0.0), area, centroid, bbox, offset, length }) +} + +/// The pin's offset to the hole, when the two are nearly on one cylinder but not on the same +/// surface: the unit direction from the pin's axis to the hole's (any, for one axis) and the +/// distance. +fn near(pin: &Wall, hole: &Wall, same: f64) -> Option<(Vec3, f64)> { + let d = hole.origin - pin.origin; + let across = d - pin.axis * d.dot(pin.axis); + let offset = across.len(); + let gap = (pin.radius - hole.radius).abs(); + if offset <= same && gap <= same { + return None; + } + if !(offset + gap <= NEAR * pin.radius.min(hole.radius)) { + return None; + } + let toward = across.normalized().unwrap_or_else(|| pin.axis.any_perp()); + Some((toward, offset)) +} + +/// The body's cylindrical faces, each as a wall of its own with no side or extent yet. +fn cylinders(b: &Body) -> Vec { + (0..b.face_count()) + .filter_map(|i| match crate::face_surface(b, i)? { + FaceSurface::Cylinder { origin, axis, radius } if origin.is_finite() && radius.is_finite() && radius > 0.0 => { + Some(Wall { faces: vec![i], origin, axis: axis.normalized()?, radius, boss: false, spans: Vec::new() }) + } + _ => None, + }) + .collect() +} + +/// The cylindrical walls of a body from its `cylinders`: its faces on each cylinder, which side +/// its material is on and where they run along the axis. `None` when the body can't be meshed. +fn walls(b: &Body, cylinders: Vec, same: f64) -> Option> { + let mut out: Vec = Vec::new(); + let mesh = b.tessellate_with((b.size() * 1e-3).max(1e-4), true).ok()?; + for Wall { faces, origin, axis, radius, .. } in cylinders { + let Some(&face) = faces.first() else { continue }; + // Which way the face looks: out of the axis for a pin, toward it for a hole. + let (mut facing, mut lo, mut hi) = (0.0, f64::INFINITY, f64::NEG_INFINITY); + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *f as usize != face { + continue; + } + let Some([p, q, r]) = mesh.tri(t) else { continue }; + let c = (p + q + r) / 3.0; + let radial = (c - origin) - axis * (c - origin).dot(axis); + facing += (q - p).cross(r - p).dot(radial); + for x in [p, q, r] { + let s = (x - origin).dot(axis); + lo = lo.min(s); + hi = hi.max(s); + } + } + if !(lo < hi) || facing == 0.0 { + continue; + } + let boss = facing > 0.0; + let wall = Wall { faces: vec![face], origin, axis, radius, boss, spans: vec![(lo, hi)] }; + match out.iter_mut().find(|w| w.boss == boss && on_one_cylinder(w, &wall, same)) { + Some(w) => { + let s = (origin - w.origin).dot(w.axis) * if axis.dot(w.axis) < 0.0 { -1.0 } else { 1.0 }; + let (a, b) = if axis.dot(w.axis) < 0.0 { (-hi, -lo) } else { (lo, hi) }; + w.faces.push(face); + w.spans.push((a + s, b + s)); + } + None => out.push(wall), + } + } + Some(out) +} + +fn on_one_cylinder(x: &Wall, y: &Wall, same: f64) -> bool { + let d = y.origin - x.origin; + x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < same && (d - x.axis * d.dot(x.axis)).len() < same +} + +/// `wall`'s spans along `onto`'s axis. +fn project(wall: &Wall, onto: &Wall) -> Vec<(f64, f64)> { + let flip = wall.axis.dot(onto.axis) < 0.0; + let s = (wall.origin - onto.origin).dot(onto.axis); + wall.spans.iter().map(|&(lo, hi)| if flip { (s - hi, s - lo) } else { (s + lo, s + hi) }).collect() +} + +/// Sorted, disjoint intervals covering `spans`. +fn union(spans: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut v: Vec<(f64, f64)> = spans.iter().copied().filter(|(a, b)| a < b).collect(); + v.sort_by(|x, y| x.0.total_cmp(&y.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); + for (a, b) in v { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +fn intersect(x: &[(f64, f64)], y: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + for &(a, b) in x { + for &(c, d) in y { + let (lo, hi) = (a.max(c), b.min(d)); + if lo < hi { + out.push((lo, hi)); + } + } + } + out +} + +fn total(spans: &[(f64, f64)]) -> f64 { + spans.iter().map(|(a, b)| b - a).sum() +} + +/// The part of a disc of radius `pin` outside a disc of radius `hole` whose centre is `offset` +/// away along +x: its area, its centroid's x (y is 0) and its perimeter. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Crescent { + pub(crate) area: f64, + pub(crate) centroid: f64, + pub(crate) perimeter: f64, +} + +pub(crate) fn crescent(pin: f64, hole: f64, offset: f64) -> Crescent { + use std::f64::consts::PI; + let (r, q, e) = (pin.max(0.0), hole.max(0.0), offset.abs()); + let disc = PI * r * r; + if e >= r + q { + return Crescent { area: disc, centroid: 0.0, perimeter: 2.0 * PI * r }; + } + if e <= (r - q).abs() { + return if q >= r { + Crescent { area: 0.0, centroid: 0.0, perimeter: 0.0 } + } else { + let area = disc - PI * q * q; + Crescent { area, centroid: -PI * q * q * e / area, perimeter: 2.0 * PI * (r + q) } + }; + } + // Half-angles of the lens's two segments, seen from each centre. + let alpha = ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos(); + let beta = ((e * e + q * q - r * r) / (2.0 * e * q)).clamp(-1.0, 1.0).acos(); + let segment = |radius: f64, half: f64| { + let area = radius * radius * (half - half.sin() * half.cos()); + let w = 2.0 * half - (2.0 * half).sin(); + let from_centre = if w > 1e-12 { 4.0 * radius * half.sin().powi(3) / (3.0 * w) } else { radius }; + (area, from_centre) + }; + let (sa, da) = segment(r, alpha); + let (sb, db) = segment(q, beta); + let lens = sa + sb; + let area = (disc - lens).max(0.0); + let moment = -(sa * da + sb * (e - db)); + let centroid = if area > 0.0 { moment / area } else { 0.0 }; + Crescent { area, centroid, perimeter: r * (2.0 * PI - 2.0 * alpha) + q * 2.0 * beta } +} + +/// Points around the crescent's outline (x toward the hole's centre, y across), for its box. +fn crescent_outline(pin: f64, hole: f64, offset: f64) -> Vec<(f64, f64)> { + let (r, q, e) = (pin, hole, offset); + let alpha = if e > 0.0 { ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos() } else { 0.0 }; + let n = 32; + (0..=n) + .map(|i| { + let t = alpha + (2.0 * std::f64::consts::PI - 2.0 * alpha) * f64::from(i) / f64::from(n); + (r * t.cos(), r * t.sin()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The crescent against the textbook lens of two equal circles, and its limits. + #[test] + fn crescent_matches_the_lens_formula() { + let (r, e) = (3.0f64, 0.01f64); + let lens = 2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt(); + let c = crescent(r, r, e); + assert!((c.area - (std::f64::consts::PI * r * r - lens)).abs() < 1e-12, "{c:?}"); + // Thin: twice the radius times the offset, its centroid π/4 of the radius away from + // the hole's centre, its outline nearly two half circles. + assert!((c.area - 2.0 * r * e).abs() < 1e-6, "{c:?}"); + assert!((c.centroid + std::f64::consts::FRAC_PI_4 * r).abs() < 1e-2, "{c:?}"); + assert!((c.perimeter - 2.0 * std::f64::consts::PI * r).abs() < 1e-3, "{c:?}"); + assert_eq!(crescent(r, r, 0.0).area, 0.0); + // A pin with clearance in its hole shares nothing; a pin wider than its hole a ring. + assert_eq!(crescent(2.99, 3.0, 0.005).area, 0.0); + let ring = crescent(3.01, 3.0, 0.0); + assert!((ring.area - std::f64::consts::PI * (3.01f64.powi(2) - 9.0)).abs() < 1e-12, "{ring:?}"); + } + + /// A 20 x 20 x 5 plate with a hole of radius 3 through it, and a pin of radius `pin` through + /// the hole, `offset` off its axis along X. + fn plate_and_pin(pin: f64, offset: f64) -> (Body, Body) { + let block = crate::box_solid(Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 5.0)).unwrap(); + let drill = crate::cylinder(Vec3::new(0.0, 0.0, -2.0), Vec3::Z, 3.0, 9.0).unwrap(); + let plate = crate::boolean(&block, &drill, BoolOp::Cut).unwrap().unwrap(); + (plate, crate::cylinder(Vec3::new(offset, 0.0, -2.0), Vec3::Z, pin, 9.0).unwrap()) + } + + /// The pin shares a crescent with the plate over the plate's thickness, in either order. + #[test] + fn a_pin_off_its_hole_shares_a_crescent() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let want = 5.0 * crescent(3.0, 3.0, 0.01).area; + for (x, y) in [(&plate, &pin), (&pin, &plate)] { + let o = coaxial_overlap(x, y).unwrap(); + assert!((o.volume - want).abs() < 1e-6 * want, "{o:?} {want}"); + assert!((o.offset - 0.01).abs() < 1e-9 && (o.length - 5.0).abs() < 1e-9, "{o:?}"); + // The crescent is on the pin's side away from the hole's axis (+X). + assert!(o.centroid.x > 2.0 && o.bbox.min.z > -1e-9 && o.bbox.max.z < 5.0 + 1e-9, "{o:?}"); + } + } + + /// A pin with clearance all round, though off the axis, shares nothing. + #[test] + fn a_pin_with_clearance_shares_nothing() { + let (plate, pin) = plate_and_pin(2.995, 0.003); + let o = coaxial_overlap(&plate, &pin).unwrap(); + assert!(o.volume < 1e-9, "{o:?}"); + } + + /// A slot through the hole's wall where the crescent would be: the band holds more than the + /// two walls, the widened pin says so, and the pair is left to the boolean. + #[test] + fn a_slot_through_the_band_is_left_to_the_boolean() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let slot = crate::box_solid(Vec3::new(2.5, -1.0, -1.0), Vec3::new(4.0, 1.0, 6.0)).unwrap(); + let slotted = crate::boolean(&plate, &slot, BoolOp::Cut).unwrap().unwrap(); + assert!(coaxial_overlap(&slotted, &pin).is_none()); + // Pins on the very axis, or far off it, aren't asked about. + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.0).1).is_none()); + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.5).1).is_none()); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 814d935a..7775d2b4 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -28,6 +28,7 @@ mod boundary; mod bridge; mod build; mod clearance; +mod coaxial_overlap; mod continuity; mod coplanar; mod corner_g2; @@ -139,6 +140,7 @@ pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus, }; +pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap}; pub use continuity::{ ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, }; From c38992fc09f1119bf52bfd7354648e8037f71235 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:34 +0200 Subject: [PATCH 104/149] fix: booleans with whole-turn round faces read from STEP are exact instead of failing (#1892) Thanks @NOWN-DEV. --- crates/kernel/src/lib.rs | 1 + crates/kernel/src/ops.rs | 12 +++ crates/kernel/src/seam_faces.rs | 92 ++++++++++++++++++ crates/kernel/src/seam_faces_tests.rs | 94 +++++++++++++++++++ vendor/README.md | 6 ++ .../src/decorators/revolved_curve.rs | 32 ++++++- 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 crates/kernel/src/seam_faces.rs create mode 100644 crates/kernel/src/seam_faces_tests.rs diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 7775d2b4..b443effa 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -101,6 +101,7 @@ mod prism_local; mod prism_measure; pub mod provenance; mod record; +mod seam_faces; mod sew; mod smooth_edges; mod spline_conv; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index c6dedf91..a8b57a48 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -908,6 +908,18 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) - return Ok(Some(body)); } } + // A whole turn of a cylinder or cone bounded by one wire along its seam (as STEP files + // write them) can't be divided by a loop lying inside it, and the tries below end in a + // nudged tool, or fail: the same boolean with such faces in two halves, which is exact. + if attempt == 1 && crate::seam_faces::needed(a, b) { + let (halves, diagnostics) = capture_boolean_diagnostics(|| crate::seam_faces::boolean(a, b, op, &boolean)); + if let Some(Ok(r)) = halves + && r.as_ref().is_none_or(|body| check(body).0) + { + merge(diagnostics); + return Ok(r); + } + } let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); for k in [2e-3, 5e-4] { let tol = (size * k).max(1e-5); diff --git a/crates/kernel/src/seam_faces.rs b/crates/kernel/src/seam_faces.rs new file mode 100644 index 00000000..d2ec9044 --- /dev/null +++ b/crates/kernel/src/seam_faces.rs @@ -0,0 +1,92 @@ +//! Faces that run along a seam edge twice: a whole turn of a cylinder or a cone bounded by one +//! wire, as STEP files write them (the wire goes round one end, along the seam, round the other +//! end and back along the seam). SolidCraft builds its own round faces in pieces with no such +//! seam, and a boolean that has to divide one of these faces can fail where the same bodies with +//! the face in two halves work: an intersection loop lying wholly inside the face is a hole in a +//! piece whose outer wire visits the seam's vertices twice, which the boolean refuses. So when the +//! first tries at the boolean of the bodies as they are fail, each such face is split in two along +//! a plane through its axis, a quarter turn either side of the seam (the solids are unchanged), and +//! the boolean is asked again; the result's history is composed back over the operands as they +//! were given. + +use solidcraft_geom::{Plane, Vec3}; + +use crate::body::{Body, from_p3}; +use crate::boolean_record as recorded; +use crate::history::History; +use crate::splitface::{SplitTool, split_any}; +use crate::{BoolOp, FaceSurface, Result, face_surface}; + +/// Most faces split before the boolean. +const MAX_SPLITS: usize = 64; + +thread_local! { + /// Set while the boolean of the split bodies runs (no second round of splitting). + static SPLITTING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The first face of `body` whose wire runs along a seam edge twice, on a cylinder or a cone, +/// with the plane that splits it in two: through its axis, square to the seam. +fn next_split(body: &Body) -> Option<(usize, Plane)> { + body.solid.face_iter().enumerate().find_map(|(i, face)| { + let mut seen = std::collections::HashSet::new(); + let seam = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).find(|e| !seen.insert(e.id()))?; + let (origin, axis) = match face_surface(body, i)? { + FaceSurface::Cylinder { origin, axis, .. } => (origin, axis), + FaceSurface::Cone { apex, axis, .. } => (apex, axis), + _ => return None, + }; + let axis = axis.normalized()?; + let p = from_p3(seam.front().point()); + let off = p - origin; + let radial: Vec3 = (off - axis * off.dot(axis)).normalized()?; + Some((i, Plane::from_normal(origin, radial)?)) + }) +} + +/// Whether either body has a face for [`boolean`] to split. +pub(crate) fn needed(a: &Body, b: &Body) -> bool { + !a.is_mesh() && !b.is_mesh() && !SPLITTING.with(|c| c.get()) && (next_split(a).is_some() || next_split(b).is_some()) +} + +/// `body` with every face that runs along a seam edge twice split in two, and its history over +/// `body`. `None` when a split fails. +fn halved(body: &Body) -> Option<(Body, Option)> { + let (mut x, mut h) = (body.clone(), Some(History::identity(body))); + for _ in 0..MAX_SPLITS { + let Some((face, plane)) = next_split(&x) else { return Some((x, h)) }; + let (mut done, _) = split_any(&x, &[face], &SplitTool::Plane(plane)).ok()?; + // The split is no authored boundary: a later feature may still merge what it separates. + done.regularization_protected = x.regularization_protected; + let through = recorded::through(&done, 0, h.as_ref()); + (x, h) = (done, through); + } + next_split(&x).is_none().then_some((x, h)) +} + +/// The boolean `op` of `a` and `b` with their faces that run along a seam edge twice split in +/// two, `run` doing the boolean of the split bodies. `None` when neither body has such a face, a +/// split fails, or the boolean of the split bodies gave no (checked) result: the caller then +/// keeps what it had. +pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp, run: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Option>> { + if !needed(a, b) { + return None; + } + let ((x, hx), (y, hy)) = (halved(a)?, halved(b)?); + SPLITTING.with(|c| c.set(true)); + let result = crate::guard("boolean of faces split off their seams", || run(&x, &y, op)); + SPLITTING.with(|c| c.set(false)); + let Ok(result) = result else { return None }; + // Held to what a boolean can do, as a retry's result is (see `boolean_guard`). + crate::boolean_guard::check_retry(a, b, op, result.as_ref()).ok()?; + let counts = crate::record::counts(&[a, b]); + Some(Ok(result.map(|body| { + // Each step's history over the operands `[a, b]`. + let history = recorded::of_pair(&body, &recorded::placed(hx.clone(), 0, 2), &recorded::placed(hy.clone(), 1, 2)); + body.with_checked_history(history, &counts) + }))) +} + +#[cfg(test)] +#[path = "seam_faces_tests.rs"] +mod tests; diff --git a/crates/kernel/src/seam_faces_tests.rs b/crates/kernel/src/seam_faces_tests.rs new file mode 100644 index 00000000..9bf32aee --- /dev/null +++ b/crates/kernel/src/seam_faces_tests.rs @@ -0,0 +1,94 @@ +//! Round faces read from STEP: a whole turn bounded by one wire that runs along its seam twice. +//! A boolean whose intersection loop lies wholly inside such a face takes it in two halves +//! (exactly), where it used to end in a nudged tool or fail. +use crate::*; +use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; +use std::f64::consts::PI; + +const R: f64 = 4.5; + +fn volume(b: &Body) -> f64 { + measure(b).unwrap().volume +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// `body` written to STEP and read back: its round faces come back whole, as other systems +/// write them. +fn through_step(body: &Body) -> Body { + step_import(&step_export(&[body], "t").unwrap()).unwrap().bodies.into_iter().next().unwrap().body +} + +/// A pin of radius `R` along X, 40 long, centred on the origin. +fn pin() -> Body { + cylinder(Vec3::new(-20.0, 0.0, 0.0), Vec3::X, R, 40.0).unwrap() +} + +/// A block pressed into the top of the pin's side: 10 long, 6 wide, from 2 above the axis up. +fn block() -> Body { + box_solid(Vec3::new(-5.0, -3.0, 2.0), Vec3::new(5.0, 3.0, 9.0)).unwrap() +} + +/// The part of the pin inside [`block`]: the circle's segment above 2 and within 3 of the +/// middle, 10 long. +fn pressed_volume() -> f64 { + let segment = 3.0 * (R * R - 9.0).sqrt() + R * R * (3.0 / R).asin() - 6.0 * 2.0; + 10.0 * segment +} + +/// The boolean, which must be exact: no retry that moves the tool or ignores part of where the +/// bodies meet. +fn exact(a: &Body, b: &Body, op: BoolOp) -> Option { + let (r, report) = capture_boolean_diagnostics(|| boolean(a, b, op)); + assert!(report.warning().is_none(), "{op:?}: {:?}", report.warning()); + r.unwrap() +} + +#[test] +fn a_pin_read_from_step_has_one_face_along_its_seam() { + let read = through_step(&pin()); + assert_eq!(read.face_count(), 3); + assert!(super::needed(&read, &block())); + assert!(!super::needed(&pin(), &block())); + // Split in two halves, with the same solid. + let (halved, history) = super::halved(&read).unwrap(); + assert!(super::next_split(&halved).is_none() && halved.face_count() > 3); + assert!(history.is_some()); + assert!(rel(volume(&halved), PI * R * R * 40.0) < 1e-3); +} + +#[test] +fn a_block_pressed_into_a_read_pins_side_is_intersected_and_cut_exactly() { + let (read, block) = (through_step(&pin()), block()); + let want = pressed_volume(); + let shared = exact(&read, &block, BoolOp::Intersect).unwrap(); + assert!(rel(volume(&shared), want) < 1e-3, "{} {want}", volume(&shared)); + let dented = exact(&read, &block, BoolOp::Cut).unwrap(); + assert!(rel(volume(&dented), PI * R * R * 40.0 - want) < 1e-3, "{}", volume(&dented)); + // The operands' faces carry over: the dented pin's round face is still the pin's. + assert!(dented.history().is_some()); +} + +#[test] +fn a_ball_pressed_into_a_read_pins_side_matches_the_built_pin() { + let ball = sphere(Vec3::new(3.0, 1.0, 5.5), 3.0).unwrap(); + let built = volume(&boolean(&pin(), &ball, BoolOp::Intersect).unwrap().unwrap()); + let read = volume(&exact(&through_step(&pin()), &ball, BoolOp::Intersect).unwrap()); + assert!(rel(read, built) < 1e-3, "{read} {built}"); +} + +/// A cone's side read from STEP is one face along its seam as well. +#[test] +fn a_block_pressed_into_a_read_cones_side_matches_the_built_cone() { + let outline = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(8.0, 0.0), Vec2::new(5.0, 20.0), Vec2::new(0.0, 20.0)]), holes: vec![] }; + let cone = revolve(&Plane::YZ, &[outline], Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap(); + let read = through_step(&cone); + assert!(super::needed(&read, &cone)); + let block = box_solid(Vec3::new(-2.0, 4.0, 6.0), Vec3::new(2.0, 12.0, 12.0)).unwrap(); + let built = volume(&boolean(&cone, &block, BoolOp::Intersect).unwrap().unwrap()); + let read = volume(&exact(&read, &block, BoolOp::Intersect).unwrap()); + assert!(rel(read, built) < 1e-3, "{read} {built}"); +} diff --git a/vendor/README.md b/vendor/README.md index d3c24a17..36bc8f17 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -53,6 +53,12 @@ Changes: farther from the leader's point than the curve's tolerance (the projection settled on another part of a surface, such as the far side of a small hole) is dropped for the search from nothing, as before. `crates/kernel/src/issue_1038_tests.rs` holds it to that search. +- `decorators/revolved_curve.rs`: a point on a revolved surface's seam (angle 0, the same place + as 2π) takes the end of the period nearer the hinted angle. The angle was decided by the sign of + a cross product that is rounding noise there, so a boundary followed point by point could come + back to the seam at the other end of the period and fold its parameter polygon over itself (no + intersection loop then lay inside the face). `crates/kernel/src/seam_faces_tests.rs` holds it + to a cone read from STEP. ## truck-stepio 0.3.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) diff --git a/vendor/truck-geometry/src/decorators/revolved_curve.rs b/vendor/truck-geometry/src/decorators/revolved_curve.rs index ee4bd7c6..5bee821b 100644 --- a/vendor/truck-geometry/src/decorators/revolved_curve.rs +++ b/vendor/truck-geometry/src/decorators/revolved_curve.rs @@ -82,6 +82,20 @@ impl Revolution { } } +/// SolidCraft: the angle of a point on the seam, where 0 and 2π are the same place, taken at the end +/// of the period nearer the hint's angle. [`Revolution::proj_angle`] decides it by the sign of a cross +/// product that is rounding noise there, so a boundary followed point by point could come back to +/// the seam at the other end of the period from where it left, and its parameter polygon folded +/// over itself (a whole turn of a cylinder had no area, and no intersection loop lay inside it). +fn seam_side(angle: f64, hint: Option) -> f64 { + const SEAM: f64 = 1.0e-7; + match hint { + Some(h) if angle < SEAM && h > PI => angle + 2.0 * PI, + Some(h) if angle > 2.0 * PI - SEAM && h < PI => angle - 2.0 * PI, + _ => angle, + } +} + impl ParametricSurface for RevolutedCurve { type Point = Point3; type Vector = Vector3; @@ -330,14 +344,19 @@ impl SearchParameter for RevolutedCurve revolution: self.revolution, }; let p = self.revolution.proj_point(point); - let hint0 = match hint.into() { + let hint = hint.into(); + let hint_angle = match hint { + SPHint2D::Parameter(_, y) => Some(y), + _ => None, + }; + let hint0 = match hint { SPHint2D::Parameter(x, _) => SPHint1D::Parameter(x), SPHint2D::Range((x0, _), (x1, _)) => SPHint1D::Range(x0, x1), SPHint2D::None => SPHint1D::None, }; let t = proj_curve.search_parameter(p, hint0, trials)?; let p = self.curve.subs(t); - let ang = self.revolution.proj_angle(p, point); + let ang = seam_side(self.revolution.proj_angle(p, point), hint_angle); match self.subs(t, ang).near(&point) { true => Some((t, ang)), false => None, @@ -377,14 +396,19 @@ impl SearchNearestParameter for Revolut revolution: self.revolution, }; let p = self.revolution.proj_point(point); - let hint0 = match hint.into() { + let hint = hint.into(); + let hint_angle = match hint { + SPHint2D::Parameter(_, y) => Some(y), + _ => None, + }; + let hint0 = match hint { SPHint2D::Parameter(x, _) => SPHint1D::Parameter(x), SPHint2D::Range((x0, _), (x1, _)) => SPHint1D::Range(x0, x1), SPHint2D::None => SPHint1D::None, }; let t = proj_curve.search_nearest_parameter(p, hint0, trials)?; let p = self.curve.subs(t); - Some((t, self.revolution.proj_angle(p, point))) + Some((t, seam_side(self.revolution.proj_angle(p, point), hint_angle))) } } } From 3eae8c34f66085f16d35f72f46a60e866853593d Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:39 +0200 Subject: [PATCH 105/149] Fillet beside a hole: the suggested largest radius fits (#324) (#1885) Thanks @SychPL. --- crates/kernel/src/blend_fit.rs | 15 +++++++---- crates/kernel/src/issue_324_tests.rs | 37 ++++++++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 ++ 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 crates/kernel/src/issue_324_tests.rs diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index 8deb9063..e0c7a351 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -360,11 +360,14 @@ enum Clash { Blend, } -fn first_clash(sel: &Selection, size: Size) -> Option { +/// What a blend at `size` runs into first, if anything. Curved faces are meshed to within the +/// chord: they count when their mesh reaches into a wedge by more than twice the chord, or, when +/// `strict`, when it comes within the chord of one (the surface may then be inside it). +fn first_clash(sel: &Selection, size: Size, strict: bool) -> Option { let wedges: Vec> = sel.frames.iter().map(|f| f.as_ref().and_then(|f| f.wedge(size))).collect(); // Planar faces are exact in the mesh; curved ones are within the chord. let flat = (sel.size * 1e-7).max(1e-9); - let curved = sel.chord * 2.0; + let curved = if strict { -sel.chord } else { sel.chord * 2.0 }; // Planar faces beside an edge lie in its wedge's side planes: outside it means further. let out = (sel.size * 1e-6).max(1e-8); for (i, (f, w)) in sel.frames.iter().zip(&wedges).enumerate() { @@ -433,8 +436,10 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> if sel.frames.iter().all(Option::is_none) || !size.value().is_finite() || size.value() <= 0.0 { return Ok(None); } - let Some(clash) = first_clash(sel, size) else { return Ok(None) }; - // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). + let Some(clash) = first_clash(sel, size, false) else { return Ok(None) }; + // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). Strictly: a size + // offered as fitting must fit, though a curved face that only touches the wedge at it would + // not be refused (a hole whose rim the round's band just reaches, #324). let asked = size.value(); let q = step(asked); let (mut lo, mut hi) = (0u64, (asked / q).ceil() as u64); @@ -443,7 +448,7 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> break; } let mid = lo + (hi - lo) / 2; - if first_clash(sel, size.scaled(mid as f64 * q / asked)).is_none() { + if first_clash(sel, size.scaled(mid as f64 * q / asked), true).is_none() { lo = mid; } else { hi = mid; diff --git a/crates/kernel/src/issue_324_tests.rs b/crates/kernel/src/issue_324_tests.rs new file mode 100644 index 00000000..57cb86a5 --- /dev/null +++ b/crates/kernel/src/issue_324_tests.rs @@ -0,0 +1,37 @@ +//! #324: a round on a block's top edge that would overlap a through hole is refused, and the +//! largest radius the refusal offers fits. + +use solidcraft_geom::Vec3; + +use crate::{Body, BoolOp, build::box_solid, build::cylinder, ops::boolean}; + +/// A 40 x 20 x 10 block with a through hole of radius 3 at (20, `y`). +fn block_with_hole(y: f64) -> Body { + let block = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 10.0)).unwrap(); + let hole = cylinder(Vec3::new(20.0, y, -1.0), Vec3::Z, 3.0, 12.0).unwrap(); + boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap() +} + +/// The round on the top edge at y = 20 covers the top face from y = 20 - r, and the hole's +/// wall reaches y = 17.5: a radius over 2.5 runs into the hole. The refusal of 4 mm offered +/// 2.721 mm, which was refused the same way: the curved wall's mesh, with its tolerance, was +/// first seen inside the round's region 0.22 mm past where the wall really enters it. +#[test] +fn the_radius_offered_beside_a_hole_fits() { + let b = block_with_hole(14.5); + let edge = Vec3::new(10.0, 20.0, 10.0); + let m = crate::fillet(&b, &[edge], 4.0).unwrap_err().to_string(); + let offered: f64 = m + .split("the largest radius that fits is ") + .nth(1) + .and_then(|s| s.split(' ').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("no radius offered: {m}")); + assert!(offered > 2.0 && offered <= 2.5, "{m}"); + let r = crate::fillet(&b, &[edge], offered).unwrap(); + assert!(r.validity().is_empty(), "{:?}", r.validity()); + // The block, the hole and the round's corner: the round stays clear of the hole. + let want = 8000.0 - std::f64::consts::PI * 9.0 * 10.0 - 40.0 * offered * offered * (1.0 - std::f64::consts::FRAC_PI_4); + let v = r.volume_at(0.005).unwrap(); + assert!((v - want).abs() < 0.05, "{v} against {want}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b443effa..b2be9b2f 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -56,6 +56,8 @@ mod issue_1038_tests; #[cfg(test)] mod issue_1080_tests; #[cfg(test)] +mod issue_324_tests; +#[cfg(test)] mod issue_616_tests; #[cfg(test)] mod issue_674_tests; From a2245b2f51020d1bd2c4abc84854e337d45090b7 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:43 +0200 Subject: [PATCH 106/149] Sketch profiles: a line passing next to a junction splits the loop it crosses (#755) (#1879) Thanks @SychPL. --- crates/sketch/src/profiles.rs | 55 +++++++++++++++------- crates/sketch/src/tests.rs | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 2b6d4a96..80346b94 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -639,7 +639,9 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { _ => false, }; let grid = Grid::new(&boxes); - let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; + // Each cut's parameter, and whether it is weak: a crossing next to an end that misses one of + // the two curves (below), which gives way to any other cut it is merged with. + let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; // End points of other curves lying on each shape. let mut end_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); for (k, p) in ends.iter().enumerate() { @@ -657,7 +659,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { && let Some(t) = sh.param_on(*p, tol) && let Some(cu) = cuts.get_mut(i) { - cu.push(t); + cu.push((t, false)); } } } @@ -752,30 +754,35 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { for p in pts { let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol)); let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol)); - // Crossings at (or next to) curve ends are already cuts. - let near_end = || { + // Crossings at (or next to) curve ends are already cuts. Not next to an end of + // one of the two curves that misses the other: nothing cut the other curve + // there, and it would cross without meeting it (a line 3e-5 from the + // junction of two curves of a loop, #755). Such a crossing is a weak cut. + let on = |sh: &Shape, e: Vec2| sh.param_on(e, tol).is_some() || end_param(sh, e, tol).is_some(); + let misses = |e: Vec2| (end_param(x, e, tol).is_some() && !on(y, e)) || (end_param(y, e, tol).is_some() && !on(x, e)); + let near_ends = || { let (cx, cy) = grid.cell(p); - (-1..=1).any(|dx| { - (-1..=1).any(|dy| { - end_cells - .get(&(cx + dx, cy + dy)) - .is_some_and(|v| v.iter().any(|k| ends.get(*k).is_some_and(|e| e.dist(p) < tol * 100.0))) - }) - }) + (-1..=1) + .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) + .flat_map(|c| end_cells.get(&c).map(Vec::as_slice).unwrap_or(&[])) + .filter_map(|k| ends.get(*k).copied()) + .filter(|e| e.dist(p) < tol * 100.0) + .fold((false, false), |(cut, weak), e| if misses(e) { (cut, true) } else { (true, weak) }) }; - if on_x.is_none() || on_y.is_none() || near_end() { + let (already, weak) = near_ends(); + if on_x.is_none() || on_y.is_none() || already { continue; } if let (Some(tx), Some(ty)) = (on_x, on_y) { if x.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(i) { - c.push(tx); + c.push((tx, weak)); } if y.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(j) { - c.push(ty); + c.push((ty, weak)); } if (gi, gj) != (i, j) { anchors.push(p); @@ -815,21 +822,33 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); - ts.sort_by(f64::total_cmp); + ts.sort_by(|a, b| a.0.total_cmp(&b.0)); // Cuts closer than a few tolerances are one cut (tangent touches give near-double roots). + // A weak cut gives way to the other: it only adds a cut where there was none. let scale = match *sh { Shape::Line { a, b } => a.dist(b), Shape::Round { r, .. } => r, }; let close = (tol * 100.0 / scale.max(1e-12)).max(1e-9); - ts.dedup_by(|a, b| (*a - *b).abs() < close); + ts.dedup_by(|a, b| { + let same = (a.0 - b.0).abs() < close; + if same && b.1 && !a.1 { + *b = *a; + } + same + }); if let (Shape::Round { sweep, .. }, Some(&first), Some(&last)) = (*sh, ts.first(), ts.last()) && sweep >= TAU - 1e-12 && ts.len() > 1 - && (first + TAU - last).abs() < close + && (first.0 + TAU - last.0).abs() < close { - ts.pop(); + if first.1 && !last.1 { + ts.remove(0); + } else { + ts.pop(); + } } + let ts: Vec = ts.into_iter().map(|(t, _)| t).collect(); let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); let analytic = analytic(*ci); let coverage = tags.get(k).copied().flatten().or_else(|| { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index f6651e71..c489d0c4 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -967,3 +967,91 @@ fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); } + +/// #755: a line drawn through a junction of a loop, but 2e-5 off it, still splits the loop. The +/// junction lies on the loop's two curves only, so it cuts neither of them where the line +/// crosses, and that crossing, next to the junction, was taken for one already cut there: the +/// line crossed the loop without meeting it, and the sketch had no profiles at all. +#[test] +fn profiles_line_passing_next_to_a_junction_splits_the_loop() { + let o = v(0.0, 0.0); + let at = |r: f64, a: f64| v(r * a.cos(), r * a.sin()); + // A slot: half circles of radius 1 round (0, 0) and (-5, 0), joined by the lines y = 1 and + // y = -1, which meet the arcs tangentially at (0, 1) and (0, -1). + let mut sk = Sketch::new(); + let right = sk.add_arc(o, v(0.0, -1.0), v(0.0, 1.0), [None; 3], None).unwrap(); + let left = sk.add_arc(v(-5.0, 0.0), v(-5.0, 1.0), v(-5.0, -1.0), [None; 3], None).unwrap(); + let ends = |i: usize| match sk.curves[i].kind { + CurveKind::Arc { a, b, .. } => (a, b), + _ => panic!("an arc"), + }; + let ((r0, r1), (l0, l1)) = (ends(right), ends(left)); + sk.add_line_pts(r1, l0, None).unwrap(); + sk.add_line_pts(l1, r0, None).unwrap(); + // Lines from (0, 0) through the junctions to an arc of radius 2. The upper one is turned by + // 2e-5 rad, so it passes 2e-5 from (0, 1) and crosses the line y = 1 next to it. + let d = 2e-5; + let (top, bottom) = (at(2.0, FRAC_PI_2 + d), v(0.0, -2.0)); + sk.add_line(o, top, None, None, None).unwrap(); + sk.add_line(o, bottom, None, None, None).unwrap(); + sk.add_arc(o, bottom, top, [None; 3], None).unwrap(); + let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + areas.sort_by(f64::total_cmp); + // The right half disc, the band round it out to radius 2, and the rest of the slot. + let want = [FRAC_PI_2, FRAC_PI_2 * (4.0 - 1.0), 10.0 + FRAC_PI_2]; + assert_eq!(areas.len(), 3, "{areas:?}"); + for (got, want) in areas.iter().zip(want) { + assert!((got - want).abs() < 1e-3, "{areas:?} against {want}"); + } +} + +/// A line ending 2e-5 from a corner, next to a crossing there: the corner, where the side of the +/// square meets the long line, stays a cut of the long line, and the square stays a profile. +#[test] +fn profiles_crossing_next_to_a_corner_keeps_the_corner() { + let mut sk = Sketch::new(); + let (a, b) = (sk.add_point(v(1.0, 0.0), None).unwrap(), sk.add_point(v(2.0, 0.0), None).unwrap()); + let (c, d) = (sk.add_point(v(2.0, 1.0), None).unwrap(), sk.add_point(v(1.0, 1.0), None).unwrap()); + // The square's base is part of a longer line, which its sides end on. + sk.add_line(v(0.0, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + for (p, q) in [(b, c), (c, d), (d, a)] { + sk.add_line_pts(p, q, None).unwrap(); + } + // A line hanging across the long one, 2e-5 from the square's corner (1, 0). + sk.add_line(v(1.0 - 2e-5, -0.5), v(1.0 - 2e-5, 0.5), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line across a rectangle, and a stray line ending on it 1e-3 from where it crosses the +/// rectangle's side: both the crossing and that end cut the long line, and the rectangle is +/// split into its two halves. +#[test] +fn profiles_crossing_next_to_a_stray_end_still_splits() { + let mut sk = Sketch::new(); + rect(&mut sk, [v(0.0, -1.0), v(1.0, -1.0), v(1.0, 1.0), v(0.0, 1.0)]); + sk.add_line(v(-1.0, 0.0), v(2.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.00099998, 0.0000099), v(0.3, 0.6), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 2, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line whose own end overshoots a crossing line by 2e-5, with a rectangle standing on it 1e-3 +/// further on: the crossing next to that end gives way to the rectangle's corner, which still +/// cuts the line, and the rectangle stays a profile. +#[test] +fn profiles_crossing_next_to_an_overshooting_end_keeps_the_corner_beside_it() { + let mut sk = Sketch::new(); + sk.add_line(v(-2e-5, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.0, -0.5), v(0.0, 0.5), None, None, None).unwrap(); + let x = 0.00099999; + let p = [v(x, 0.0000099), v(x, 1.0), v(2.0, 1.0), v(2.0, 0.0)].map(|q| sk.add_point(q, None).unwrap()); + for w in p.windows(2) { + sk.add_line_pts(w[0], w[1], None).unwrap(); + } + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|a| (a - (2.0 - x)).abs() < 1e-4), "{areas:?}"); +} From ff7f3204d59dfb005ab6084ae8278beb837aa25e Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:46 +0200 Subject: [PATCH 107/149] Updater: recognise an MSI install outside Program Files (#1870) (#1873) Thanks @SychPL. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 5 +++++ crates/ui-egui/src/update.rs | 30 ++++++++++++++++++++++++++--- crates/ui-egui/src/update/tests.rs | 31 ++++++++++++++++++++++++++++-- docs/book/src/guide/updates.md | 2 +- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e325ee53..7aca27e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4255,6 +4255,7 @@ dependencies = [ "toml", "ttf-parser", "web-time", + "winreg", "zip 3.0.0", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 38c53a70..37563db2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,11 @@ zip = { workspace = true } # Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn. tiny-skia = { workspace = true } +[target.'cfg(windows)'.dependencies] +# Updates: the folder the Windows installer recorded, so an install outside Program Files is still +# updated by its installer (MIT; already in the tree as a build dependency). +winreg = "0.56" + [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # The offscreen viewport render test opens a device without a window. pollster = { workspace = true } diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index d46d8931..09dd0568 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -144,7 +144,7 @@ pub enum InstallKind { Rpm, /// The plain `.tar.gz`, unpacked somewhere. LinuxTarball, - /// Installed by the `.msi` (under Program Files). + /// Installed by the `.msi` (under Program Files, or in the folder the installer recorded). WindowsMsi, /// The portable `.zip`, unpacked somewhere. WindowsPortable, @@ -165,6 +165,9 @@ pub struct Probe<'a> { /// The running executable. pub exe: Option, pub exists: &'a dyn Fn(&Path) -> bool, + /// Where the Windows installer put SolidCraft (`HKLM\Software\SolidCraft`, `InstallDir`): a + /// folder chosen in its wizard can be anywhere, not only under Program Files (#1870). + pub msi_dir: Option, } pub fn detect(p: &Probe) -> InstallKind { @@ -202,7 +205,8 @@ pub fn detect(p: &Probe) -> InstallKind { } "windows" => { let under = |k: &str| env(k).is_some_and(|dir| starts_with_ci(&exe, Path::new(&dir))); - if under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { + let installed = p.msi_dir.as_deref().is_some_and(|dir| starts_with_ci(&exe, dir)); + if installed || under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { return InstallKind::WindowsMsi; } if exe.parent().is_some_and(|d| (p.exists)(&d.join("solidcraft-cli.exe"))) { @@ -240,10 +244,30 @@ pub fn this_install() -> InstallKind { { let env = |k: &str| std::env::var(k).ok(); let exists = |p: &Path| p.exists(); - detect(&Probe { os: std::env::consts::OS, release_build: release_build(), env: &env, exe: std::env::current_exe().ok(), exists: &exists }) + let msi_dir = msi_install_dir(); + detect(&Probe { + os: std::env::consts::OS, + release_build: release_build(), + env: &env, + exe: std::env::current_exe().ok(), + exists: &exists, + msi_dir, + }) } } +/// The folder the Windows installer recorded (solidcraft.wxs writes it for upgrades), if any. +#[cfg(windows)] +fn msi_install_dir() -> Option { + let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE).open_subkey(r"Software\SolidCraft").ok()?; + key.get_value::("InstallDir").ok().map(PathBuf::from) +} + +#[cfg(all(not(windows), not(target_arch = "wasm32")))] +fn msi_install_dir() -> Option { + None +} + impl InstallKind { /// The package manager updates it: Download isn't the way (the window shows the command). pub fn system_managed(&self) -> bool { diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 3b014cdf..8eda32e7 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -32,7 +32,7 @@ fn probe(os: &str, release: bool, env: &[(&str, &str)], exe: &str, files: &[&str let files: Vec = files.iter().map(PathBuf::from).collect(); let get = |k: &str| env.get(k).cloned(); let exists = |p: &Path| files.iter().any(|f| f == p); - detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists }) + detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists, msi_dir: None }) } #[test] @@ -92,7 +92,34 @@ fn portable_windows_layout_is_recognised() { let exe = PathBuf::from("/portable/solidcraft.exe"); let get = |k: &str| (k == "ProgramFiles").then(|| "C:\\Program Files".to_string()); let exists = |p: &Path| p == Path::new("/portable/solidcraft-cli.exe"); - assert_eq!(detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists }), InstallKind::WindowsPortable); + assert_eq!( + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists, msi_dir: None }), + InstallKind::WindowsPortable + ); +} + +/// #1870: the installer's wizard can put SolidCraft anywhere, and it puts solidcraft-cli.exe +/// beside it as the portable zip does. The folder it recorded is what tells its copy apart. +#[test] +fn an_msi_install_outside_program_files_is_recognised() { + let exe = PathBuf::from(r"D:\Program Files\SolidCraft\solidcraft.exe"); + let get = |k: &str| match k { + "ProgramFiles" | "ProgramW6432" => Some(r"C:\Program Files".to_string()), + "ProgramFiles(x86)" => Some(r"C:\Program Files (x86)".to_string()), + _ => None, + }; + // solidcraft-cli.exe is beside it. + let exists = |_: &Path| true; + let kind = |msi_dir: Option<&str>| { + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe.clone()), exists: &exists, msi_dir: msi_dir.map(PathBuf::from) }) + }; + // The installer records [INSTALLFOLDER], which ends in a backslash; Windows paths ignore case. + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft\")), InstallKind::WindowsMsi); + assert_eq!(kind(Some(r"d:\program files\solidcraft")), InstallKind::WindowsMsi); + // No installer copy here: the portable zip, as before. + assert_eq!(kind(None), InstallKind::WindowsPortable); + assert_eq!(kind(Some("")), InstallKind::WindowsPortable); + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft2\")), InstallKind::WindowsPortable, "a folder whose name only starts the same"); } /// The assets of v0.10.0, as published. diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 45307590..f871c29e 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -88,7 +88,7 @@ and SolidCraft picks the one matching how it was installed: | `.deb` | under `/usr`, dpkg knows `solidcraft` | `solidcraft-linux-.deb` | `sudo apt install ./solidcraft-linux-.deb` | | `.rpm` | under `/usr`, an rpm database exists | `solidcraft-linux-.rpm` | `sudo dnf install ./solidcraft-linux-.rpm` | | `.tar.gz` | anywhere else on Linux | `solidcraft--linux-.tar.gz` | Unpack over the old copy | -| Windows installer | under Program Files | `solidcraft-windows-.msi` | Run the installer | +| Windows installer | under Program Files, or in the folder the installer recorded | `solidcraft-windows-.msi` | Run the installer | | Windows portable | `solidcraft-cli.exe` beside it | `solidcraft-windows--portable.zip` | Unzip over the old copy | | Windows `.exe` | anywhere else on Windows | `SolidCraft--windows-x64.exe` | Replace the old `.exe` | | macOS | inside `SolidCraft.app` | `solidcraft-macos-universal.dmg` | Drag the new app to Applications | From a5003da9d776fe4cfcd9c75d4727d4d2cf00a105 Mon Sep 17 00:00:00 2001 From: James Aita Date: Sat, 17 Oct 2026 04:09:48 -0400 Subject: [PATCH 108/149] Navigation: hold Space to pan (#1754) Thanks @styles01. --- crates/ui-egui/src/navigation.rs | 15 ++++++++++++-- crates/ui-egui/src/prefs.rs | 2 +- crates/ui-egui/src/viewport.rs | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 383f1bf7..6f443da3 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -387,8 +387,19 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e } else if !primary_down && !primary_released { app.viewport.primary_nav = None; } - // The fixed navigation mode (navigation bar) or the preset's left-button binding. - let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); + // Hold Space for a momentary pan: the hand tool of design apps. It overrides whatever the + // left button would otherwise do - selecting, dragging geometry, the preset's binding or the + // navigation bar's fixed mode - and lasts only while the key is held. Space still types in a + // text field, and while a dimension's text is being edited, so those keep the keyboard. + let space_pan = (inside || primary_down) + && !ui.ctx().egui_wants_keyboard_input() + && !crate::dim_view::editing() + && ui.input(|i| i.key_down(egui::Key::Space)); + if space_pan { + ui.ctx().set_cursor_icon(if primary_down { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + // The fixed navigation mode (navigation bar), a held Space, or the preset's left-button binding. + let primary_nav = if space_pan { Some(NavMode::Pan) } else { app.viewport.nav.or(app.viewport.primary_nav) }; let button = if middle { Some(crate::prefs::NavButton::Middle) } else if secondary_down { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 989c9f2a..453ce12b 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1139,7 +1139,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { note(ui, crate::i18n::tr("nav.touchpad.help")); } else { let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; - let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help); + let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; hold Space to pan; the wheel zooms", nav_preset(&app.preferences.nav).help); note(ui, crate::i18n::tr_label(&raw_help)); } ui.label(crate::i18n::tr_label("Context menu")); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 5ee09c0d..cd0b69db 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -4111,6 +4111,40 @@ mod tests { app } + fn space(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::default() } + } + + /// Holding Space is a momentary pan: the drag moves the view rather than the geometry under the + /// pointer, in every preset. The point is over the sketch's own point, so without Space this + /// drag would drag it (and the undo stack would grow). + #[test] + fn holding_space_pans_instead_of_dragging_geometry() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = preset.into(); + app.ui.show_origin = false; + hover_frame(&mut app, &ctx, vec![]); + let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); + let before = app.cam; + let undo = app.session.undo.len(); + let press = |pressed: bool, at: Pos2| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }; + hover_frame(&mut app, &ctx, vec![space(true)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point), press(true, point)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point + vec2(40.0, 25.0))]); + assert_ne!(app.cam, before, "{preset}: holding Space must pan the view"); + assert_eq!(app.session.undo.len(), undo, "{preset}: holding Space must not edit the design"); + assert!(app.viewport.point_drag.is_none(), "{preset}: holding Space must not drag geometry"); + hover_frame(&mut app, &ctx, vec![press(false, point + vec2(40.0, 25.0)), space(false)]); + } + } + #[test] fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() { for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { From c9b8267b9eb1c38e5c7c4f9859e28b1dfaaf493b Mon Sep 17 00:00:00 2001 From: phanoob Date: Sat, 17 Oct 2026 10:09:51 +0200 Subject: [PATCH 109/149] Action matrix: record the chamfers #1428 refuses (#1684) Thanks @phanoob. --- tests/action_matrix/baseline.json | 104 +++++++++++++++--------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index 96bda732..3baf01a7 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -1368,7 +1368,7 @@ "Body1/F2:start#[F2:side:l9,F5:side:l10]|F5:side:l10#[F2:side:l8,F2:start,F7:side:l12]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:start#[F2:side:l9,F5:side:l10]|F7:side:l12": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:start#[F3:side:l5,F5:side:l8]": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F2:start#[F3:side:l5,F5:side:l8]|F3:side:l5": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F2:start#[F3:side:l5,F5:side:l8]|F3:side:l5": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:start#[F3:side:l5,F5:side:l8]|F5:side:l8#[F2:side:l8,F2:start,F7:side:l12]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F2:start#[F3:side:l5,F5:side:l8]|F7:side:l12": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:top": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", @@ -1394,7 +1394,7 @@ "Body1/F3:start#[F2:side:l1,F2:side:l10,F7:side:l10]": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:start#[F2:side:l1,F2:side:l10,F7:side:l10]|F7:side:l10": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]|F7:side:l12": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]|F7:side:l12": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]|F7:side:l4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]|F9:side:l19#[F3:bottom,F3:start,F7:side:l4,F7:side:l9,F9:side:l18]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F3:start#[F3:side:l5,F7:side:l12,F7:side:l4]|F9:side:l20#[F3:bottom,F3:side:l5,F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -1427,7 +1427,7 @@ "Body1/F7:side:l10|F7:side:l9#[F2:side:l7,F5:side:l8,F7:side:l4]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F7:side:l10|F7:side:l9#[F2:side:l9,F5:side:l10]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F7:side:l11": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F7:side:l11|F7:side:l12": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F7:side:l11|F7:side:l12": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F7:side:l11|F9:side:l17#[F2:top,F7:side:l11,F9:side:l20]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F7:side:l11|F9:side:l18#[F2:side:l1,F2:top,F7:side:l11]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F7:side:l11|F9:side:l19#[F2:side:l4,F2:top,F7:side:l11]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -4661,11 +4661,11 @@ "Plate/F4:csink:side:0": "curved.axis curved.tangent_plane", "Plate/F4:csink:side:1": "curved.axis curved.tangent_plane", "Plate/F5:bore:side:1#1": "curved.axis curved.tangent_plane", - "Plate/F5:bore:side:1#1|F5:bore:side:2#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F5:bore:side:1#1|F5:bore:side:2#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F5:bore:side:1#2": "curved.axis curved.tangent_plane", - "Plate/F5:bore:side:1#2|F5:bore:side:2#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F5:bore:side:1#2|F5:bore:side:2#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F5:bore:side:1#3": "curved.axis curved.tangent_plane", - "Plate/F5:bore:side:1#3|F5:bore:side:2#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F5:bore:side:1#3|F5:bore:side:2#3": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F5:bore:side:2#1": "curved.axis curved.tangent_plane", "Plate/F5:bore:side:2#2": "curved.axis curved.tangent_plane", "Plate/F5:bore:side:2#3": "curved.axis curved.tangent_plane", @@ -5995,19 +5995,19 @@ }, "oracle/39-tee-pipe": { "Tee/F2:bottom": "face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Tee/F2:bottom|F2:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tee/F2:bottom|F2:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tee/F2:bottom|F6:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tee/F2:bottom|F6:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:bottom|F2:side:c1#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:bottom|F2:side:c1#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:bottom|F6:side:c1#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:bottom|F6:side:c1#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F2:side:c1#1": "curved.axis curved.tangent_plane", - "Tee/F2:side:c1#1|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:side:c1#1|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F2:side:c1#1|F4:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F2:side:c1#2": "curved.axis curved.tangent_plane", - "Tee/F2:side:c1#2|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:side:c1#2|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F2:side:c1#2|F4:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F2:top": "face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Tee/F2:top|F6:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tee/F2:top|F6:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:top|F6:side:c1#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tee/F2:top|F6:side:c1#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F4:side:c1#1": "curved.axis curved.tangent_plane", "Tee/F4:side:c1#1|F4:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tee/F4:side:c1#2": "curved.axis curved.tangent_plane", @@ -6110,7 +6110,7 @@ "oracle/41-tube-radial-holes": { "Tube/F2:side:c_in#1": "curved.axis curved.tangent_plane", "Tube/F2:side:c_in#1|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tube/F2:side:c_in#1|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tube/F2:side:c_in#1|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#1|F4:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#1|F4:side:c1#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#1|F4:side:c1@F5.1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -6131,7 +6131,7 @@ "Tube/F2:side:c_in#1|F4:side:c3@F5.2#4": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#2": "curved.axis curved.tangent_plane", "Tube/F2:side:c_in#2|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tube/F2:side:c_in#2|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tube/F2:side:c_in#2|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#2|F4:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#2|F4:side:c1#4": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_in#2|F4:side:c1@F5.1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -6152,7 +6152,7 @@ "Tube/F2:side:c_in#2|F4:side:c3@F5.2#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#1": "curved.axis curved.tangent_plane", "Tube/F2:side:c_out#1|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tube/F2:side:c_out#1|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tube/F2:side:c_out#1|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#1|F4:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#1|F4:side:c1#4": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#1|F4:side:c1@F5.1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -6173,7 +6173,7 @@ "Tube/F2:side:c_out#1|F4:side:c3@F5.2#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#2": "curved.axis curved.tangent_plane", "Tube/F2:side:c_out#2|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Tube/F2:side:c_out#2|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Tube/F2:side:c_out#2|F2:top": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#2|F4:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#2|F4:side:c1#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Tube/F2:side:c_out#2|F4:side:c1@F5.1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -6385,16 +6385,16 @@ "Plate/F2:start|F4:side:c1@F5.8#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F4:side:c1@F5.9#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F4:side:c1@F5.9#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:start|F7:side:c1#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:start|F7:side:c1#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Plate/F2:start|F7:side:c1#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Plate/F2:start|F7:side:c1#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.2#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.2#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.3#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.3#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:start|F7:side:c1@F8.4#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:start|F7:side:c1@F8.4#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Plate/F2:start|F7:side:c1@F8.4#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Plate/F2:start|F7:side:c1@F8.4#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.5#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.5#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:start|F7:side:c1@F8.6#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -6458,20 +6458,20 @@ "Plate/F2:top|F4:side:c1@F5.9#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.1#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.1#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.2#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.2#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.3#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.3#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.3#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.3#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.4#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.4#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.5#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.5#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.5#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.5#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.6#1": "edge.chamfer !edge.fillet edge.normal_plane edge.project", "Plate/F2:top|F7:side:c1@F8.6#2": "edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.7#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", - "Plate/F2:top|F7:side:c1@F8.7#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.7#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", + "Plate/F2:top|F7:side:c1@F8.7#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Plate/F4:side:c1#1": "curved.axis curved.tangent_plane", "Plate/F4:side:c1#2": "curved.axis curved.tangent_plane", "Plate/F4:side:c1@F5.1#1": "curved.axis curved.tangent_plane", @@ -7548,20 +7548,20 @@ "Cyl/F2:start": "face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Cyl/F2:top": "face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Cyl/F3:bore:side:1#1": "curved.axis curved.tangent_plane", - "Cyl/F3:bore:side:1#1|F3:bore:side:2#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F3:bore:side:1#1|F3:bore:side:2#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F3:bore:side:1#2": "curved.axis curved.tangent_plane", - "Cyl/F3:bore:side:1#2|F3:bore:side:2#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F3:bore:side:1#2|F3:bore:side:2#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F3:bore:side:1#3": "curved.axis curved.tangent_plane", - "Cyl/F3:bore:side:1#3|F3:bore:side:2#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F3:bore:side:1#3|F3:bore:side:2#3": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F3:bore:side:2#1": "curved.axis curved.tangent_plane", "Cyl/F3:bore:side:2#2": "curved.axis curved.tangent_plane", "Cyl/F3:bore:side:2#3": "curved.axis curved.tangent_plane", "Cyl/F4:bore:side:1#1": "curved.axis curved.tangent_plane", - "Cyl/F4:bore:side:1#1|F4:bore:side:2#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F4:bore:side:1#1|F4:bore:side:2#1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F4:bore:side:1#2": "curved.axis curved.tangent_plane", - "Cyl/F4:bore:side:1#2|F4:bore:side:2#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F4:bore:side:1#2|F4:bore:side:2#2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F4:bore:side:1#3": "curved.axis curved.tangent_plane", - "Cyl/F4:bore:side:1#3|F4:bore:side:2#3": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Cyl/F4:bore:side:1#3|F4:bore:side:2#3": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Cyl/F4:bore:side:2#1": "curved.axis curved.tangent_plane", "Cyl/F4:bore:side:2#2": "curved.axis curved.tangent_plane", "Cyl/F4:bore:side:2#3": "curved.axis curved.tangent_plane", @@ -8265,7 +8265,7 @@ "Shaft/F3:blend:F2:side:l6#2|F2:side:l7": "curved.axis curved.tangent_plane", "Shaft/F3:blend:F2:side:l6#3|F2:side:l7": "curved.axis curved.tangent_plane", "Shaft/F6:bottom": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull !face.shell face.sketch", - "Shaft/F6:bottom|F6:side:a1": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Shaft/F6:bottom|F6:side:a1": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Shaft/F6:bottom|F6:side:a2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Shaft/F6:bottom|F6:side:l1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Shaft/F6:bottom|F6:side:l2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -8723,7 +8723,7 @@ "Hinge/F2:side:l4": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Hinge/F2:side:l4|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F2:side:l4|F2:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Hinge/F2:side:l4|F4:side:c1#[F2:side:l3]#[F2:side:l4,F8:side:l4]": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Hinge/F2:side:l4|F4:side:c1#[F2:side:l3]#[F2:side:l4,F8:side:l4]": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F2:side:l4|F4:side:c1#[F2:side:l4,F8:side:l4]": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F2:side:l4|F4:side:c1#[F2:top]#[F2:side:l4,F8:side:l4]": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F2:side:l4|F6:side:c1#1#[F2:side:l4,F8:side:l4]": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -8741,7 +8741,7 @@ "Hinge/F4:side:c1#[F2:side:l2,F8:side:l2]": "curved.axis curved.tangent_plane", "Hinge/F4:side:c1#[F2:side:l2,F8:side:l2]|F8:side:l2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F4:side:c1#[F2:side:l3]#[F2:side:l2,F8:side:l2]": "curved.axis curved.tangent_plane", - "Hinge/F4:side:c1#[F2:side:l3]#[F2:side:l2,F8:side:l2]|F8:side:l2": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Hinge/F4:side:c1#[F2:side:l3]#[F2:side:l2,F8:side:l2]|F8:side:l2": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F4:side:c1#[F2:side:l3]#[F2:side:l4,F8:side:l4]": "curved.axis curved.tangent_plane", "Hinge/F4:side:c1#[F2:side:l3]#[F2:side:l4,F8:side:l4]|F8:side:l4": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Hinge/F4:side:c1#[F2:side:l4,F8:side:l4]": "curved.axis curved.tangent_plane", @@ -9829,8 +9829,8 @@ "Body1/F4:face:13|F4:face:14": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F4:face:14": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F4:face:18": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F4:face:18|F4:face:21": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F4:face:18|F4:face:9": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:face:18|F4:face:21": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:face:18|F4:face:9": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:face:19": "curved.axis curved.tangent_plane", "Body1/F4:face:19|F4:face:21": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:face:20": "curved.axis curved.tangent_plane", @@ -10177,7 +10177,7 @@ "Slat/F2:side:l1": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Slat/F2:side:l1|F2:side:l2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Slat/F2:side:l1|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Slat/F2:side:l1|F2:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Slat/F2:side:l1|F2:top": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Slat/F2:side:l2": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Slat/F2:side:l2|F2:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Slat/F2:side:l2|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -10186,7 +10186,7 @@ "Slat/F2:side:l2|F4:side:c1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Slat/F2:side:l3": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Slat/F2:side:l3|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Slat/F2:side:l3|F2:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", + "Slat/F2:side:l3|F2:top": "edge.axis !edge.chamfer edge.fillet edge.normal_plane edge.project", "Slat/F2:start": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Slat/F2:top": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Slat/F4:side:c1#1": "curved.axis curved.tangent_plane", @@ -10825,12 +10825,12 @@ "Body1/F4:bottom|F9:side:l3": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:bottom|F9:side:l4": "edge.axis edge.normal_plane edge.project", "Body1/F4:side:l1": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F4:side:l1|F4:side:l2": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", - "Body1/F4:side:l1|F4:side:l4": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:side:l1|F4:side:l2": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:side:l1|F4:side:l4": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:side:l2": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F4:side:l2|F4:side:l3": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:side:l2|F4:side:l3": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:side:l3": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", - "Body1/F4:side:l3|F4:side:l4": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F4:side:l3|F4:side:l4": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F4:side:l4": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch", "Body1/F6:bottom": "face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Body1/F6:bottom|F6:side:c1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -11044,7 +11044,7 @@ "Body1/F1:-y|F2:bottom@F3.3#[F1:-y]": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F1:-y|F2:side@F3.3#[F1:-y]": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F1:-z": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F1:-z|F2:bottom@F3.3#[F1:-z]": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F1:-z|F2:bottom@F3.3#[F1:-z]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F1:-z|F2:side#1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:-z|F2:side#2": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F1:-z|F2:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -11593,10 +11593,10 @@ "Body1/F2:top|F6:side:l7": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F2:top|F6:side:l8": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.normal_plane+edit edge.project", "Body1/F6:end": "face.extrude face.extrude+edit face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F6:end|F6:side:l5": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", - "Body1/F6:end|F6:side:l6": "edge.chamfer edge.chamfer+edit edge.fillet edge.normal_plane edge.project", - "Body1/F6:end|F6:side:l7": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", - "Body1/F6:end|F6:side:l8": "edge.chamfer edge.fillet edge.normal_plane edge.project", + "Body1/F6:end|F6:side:l5": "!edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", + "Body1/F6:end|F6:side:l6": "!edge.chamfer edge.chamfer+edit edge.fillet edge.normal_plane edge.project", + "Body1/F6:end|F6:side:l7": "!edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", + "Body1/F6:end|F6:side:l8": "!edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F6:side:l5|F6:side:l6": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit", "Body1/F6:side:l5|F6:side:l8": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Body1/F6:side:l6|F6:side:l7": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", From 64235ab12a27d2406580e6a649ab7cbdd6bbc1d1 Mon Sep 17 00:00:00 2001 From: Mikalai Kukhta <1721257+NecroKote@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:09:55 +0200 Subject: [PATCH 110/149] 3D mouse: invert and speed for each of the cap's six axes (#1814) Thanks @NecroKote. --- README.md | 3 ++- crates/ui-egui/src/prefs.rs | 26 ++++++++++++++++++++++++++ crates/ui-egui/src/space_mouse.rs | 31 ++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41871847..4e2fb0bd 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p s On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation -page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads +page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an +invert switch and a speed for the 3D mouse. The web app reads it too, through the browser; Firefox needs a 3DxWare setting first (see [Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)). diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 453ce12b..704b9cc8 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -273,6 +273,11 @@ pub struct Prefs { pub space_mouse_enabled: bool, /// Speed multiplier for 3D mouse navigation. pub space_mouse_speed: f32, + /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view + /// the other way. + pub space_mouse_reverse: [bool; 6], + /// Per physical axis of the cap, a multiplier on top of `space_mouse_speed`; 0 switches it off. + pub space_mouse_axis_speed: [f32; 6], /// Anti-aliasing samples (applies on restart). pub msaa: u32, /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or @@ -402,6 +407,8 @@ impl Default for Prefs { orbit_cursor: false, space_mouse_enabled: true, space_mouse_speed: 1.0, + space_mouse_reverse: [false; 6], + space_mouse_axis_speed: [1.0; 6], msaa: 4, display_quality: "normal".into(), supersampling: 1.0, @@ -618,6 +625,8 @@ fn defaults(app: &mut SolveApp, page: usize) { p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_speed = d.space_mouse_speed; + p.space_mouse_reverse = d.space_mouse_reverse; + p.space_mouse_axis_speed = d.space_mouse_axis_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; p.middle_double_click = d.middle_double_click; @@ -1214,6 +1223,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); + for (axis, name) in SPACE_MOUSE_AXES.iter().enumerate() { + ui.label(crate::i18n::tr_label(name)); + ui.horizontal(|ui| { + ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { + if let Some(reverse) = app.preferences.space_mouse_reverse.get_mut(axis) { + ui.checkbox(reverse, crate::i18n::tr_label("Invert")); + } + if let Some(speed) = app.preferences.space_mouse_axis_speed.get_mut(axis) { + ui.add(egui::Slider::new(speed, 0.0..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))); + } + }); + }); + ui.end_row(); + } note(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only.")); } PAGE_DESIGN => { @@ -1453,6 +1476,9 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result Prefs { if p.orbit_cursor { p.orbit_around = OrbitAround::Cursor; diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index bb9d76e0..4b4ef6fe 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -87,6 +87,16 @@ impl DriverMapping { mapping } + /// The mapping with the user's per-axis choices folded in: `reverse` flips and `speed` scales + /// each physical axis of the cap. A speed that is not a finite number counts as 1. + pub fn with_user_axes(mut self, reverse: [bool; 6], speed: [f32; 6]) -> Self { + for ((rule, reverse), speed) in self.axes.iter_mut().zip(reverse).zip(speed) { + rule.reversed ^= reverse; + rule.scale *= if speed.is_finite() { f64::from(speed).clamp(0.0, 3.0) } else { 1.0 }; + } + self + } + pub fn apply(self, axes: Axes) -> Motion { let raw = [ normalized(axes.translation[0]), @@ -139,7 +149,10 @@ pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMa app.begin_external_navigation(); // A long gap (the first frame after a pause) moves as far as a 50 ms one. let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 }; - app.viewport.device_motion = Some(DeviceMotion { motion: mapping.apply(axes), seconds: step * speed.clamp(0.1, 5.0) }); + app.viewport.device_motion = Some(DeviceMotion { + motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes), + seconds: step * speed.clamp(0.1, 5.0), + }); ctx.request_repaint_after(Duration::from_millis(16)); true } @@ -182,6 +195,22 @@ mod tests { app } + #[test] + fn per_axis_choices_flip_and_scale_one_physical_axis() { + let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]); + let still = Motion::default(); + assert_eq!(mapping.apply(Axes { translation: [0, 350, 0], rotation: [0; 3] }).translation[1], -raw(PUSH).translation[1]); + assert_eq!( + mapping.apply(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0], + raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0] + ); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [350, 0, 0] }).rotation[0], 0.5); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }).rotation[1], 1.0); + assert_eq!(mapping.apply(Axes::default()), still); + let off = DriverMapping::default().with_user_axes([false; 6], [0.0; 6]); + assert_eq!(off.apply(PUSH), still); + } + const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; fn wheel(dy: f32) -> egui::Event { From 67d4628753ee4389d236afc9a4524da3748054ef Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:10:15 -0400 Subject: [PATCH 111/149] Stand this part up? offer: handles for its buttons, and stand-up scripting (#1911) Thanks @DuBearte. --- assets/locales/de.json | 2 + assets/locales/en.json | 2 + assets/locales/ja.json | 2 + assets/locales/ko.json | 2 + assets/locales/zh.json | 2 + crates/ui-egui/src/prefs.rs | 11 ++-- crates/ui-egui/src/stand_up.rs | 28 +++++++---- crates/ui-egui/tests/stand_up_offer.rs | 70 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 9 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/stand_up_offer.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index 9394a574..50cd7a9d 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -27,6 +27,7 @@ "Current perspective view": "Aktuelle perspektivische Ansicht", "Curvature (G2)": "Krümmung (G2)", "Do not export hidden edges": "Verdeckte Kanten nicht exportieren", + "Don't ask again": "Nicht mehr fragen", "Drive Joint": "Gelenk antreiben", "Duration": "Dauer", "Edges": "Kanten", @@ -60,6 +61,7 @@ "Picked point": "Gewählter Punkt", "Plot": "Diagramm", "Position (G0)": "Position (G0)", + "Preferences › Design turns the offer back on": "Unter Einstellungen › Design lässt sich das Angebot wieder einschalten", "Profiles/Curves": "Profile/Kurven", "Projection": "Projektion", "Quantity": "Größe", diff --git a/assets/locales/en.json b/assets/locales/en.json index d23e3cb7..b9897704 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -88,6 +88,7 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.", "Discard": "Discard", "Do not export hidden edges": "Do not export hidden edges", + "Don't ask again": "Don't ask again", "Double-click to rename": "Double-click to rename", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing", "Drive Joint": "Drive Joint", @@ -182,6 +183,7 @@ "Position (0 to 1)": "Position (0 to 1)", "Position (G0)": "Position (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Position along the edge, 0 at the start end, 1 at the other", + "Preferences › Design turns the offer back on": "Preferences › Design turns the offer back on", "Preview Row": "Preview Row", "Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.", "Profiles/Curves": "Profiles/Curves", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index f12aebd1..a9483a34 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -88,6 +88,7 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。", "Discard": "破棄", "Do not export hidden edges": "出力しない", + "Don't ask again": "今後確認しない", "Double-click to rename": "ダブルクリックで名前変更", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効", "Drive Joint": "ジョイントを駆動", @@ -182,6 +183,7 @@ "Position (0 to 1)": "位置 (0〜1)", "Position (G0)": "位置 (G0)", "Position along the edge, 0 at the start end, 1 at the other": "エッジ上の位置(始点が0、終点が1)", + "Preferences › Design turns the offer back on": "環境設定 › デザイン で再びオンにできます", "Preview Row": "行をプレビュー", "Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。", "Profiles/Curves": "プロファイル/曲線", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 7d7f7d5a..43a55ee1 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -27,6 +27,7 @@ "Current perspective view": "현재 원근 투영 뷰", "Curvature (G2)": "곡률 (G2)", "Do not export hidden edges": "숨겨진 모서리 내보내지 않기", + "Don't ask again": "다시 묻지 않기", "Drive Joint": "조인트 구동", "Duration": "지속 시간", "Edges": "모서리", @@ -60,6 +61,7 @@ "Picked point": "선택한 점", "Plot": "그래프", "Position (G0)": "위치 (G0)", + "Preferences › Design turns the offer back on": "환경 설정 › 디자인에서 다시 켤 수 있습니다", "Profiles/Curves": "프로파일/곡선", "Projection": "투영", "Quantity": "양", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 2c6899a0..df2c00d6 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -88,6 +88,7 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。", "Discard": "放弃", "Do not export hidden edges": "不导出隐藏线", + "Don't ask again": "不再询问", "Double-click to rename": "双击重命名", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作", "Drive Joint": "驱动接头", @@ -182,6 +183,7 @@ "Position (0 to 1)": "位置(0 到 1)", "Position (G0)": "位置 (G0)", "Position along the edge, 0 at the start end, 1 at the other": "沿边的位置,起点端为 0,另一端为 1", + "Preferences › Design turns the offer back on": "可在 首选项 › 设计 中重新开启此提示", "Preview Row": "预览行", "Preview leaves the current design unchanged.": "预览不会更改当前设计。", "Profiles/Curves": "轮廓/曲线", diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 704b9cc8..ac2cb5bd 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1438,8 +1438,9 @@ fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) { } /// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button` -/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer; -/// any other keys set preferences. Returns them all, the open page and the offer. +/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer +/// (`false` leaves the file as it is, like "Leave it"); any other keys set preferences. Returns +/// them all, the open page and the offer. pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result { let mut rest = p.as_object().cloned().unwrap_or_default(); if let Some(page) = rest.remove("open") { @@ -1450,8 +1451,10 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result crate::stand_up::stand_up(app)?, + Some(false) => crate::stand_up::leave(app), + None => {} } let button = rest.remove("button"); let mut out = set(app, &serde_json::Value::Object(rest))?; diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index 83ba676e..83f24fc8 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -36,6 +36,12 @@ pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { Ok(()) } +/// Dismiss the offer and keep the file as it opened ("Leave it"); the preference stays on. +pub fn leave(app: &mut SolveApp) { + app.stand_up = None; + app.stand_up_occurrence = None; +} + /// The offer, under the toolbar. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if app.stand_up.is_none() { @@ -52,13 +58,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_label("Stand it up: a quarter turn about X, as one Move feature you can undo.")); ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button(crate::i18n::tr_label("Stand it up")).clicked() { + let stand = ui.button(crate::i18n::tr_label("Stand it up")); + crate::scenario::publish_handle("stand_up:stand", stand.rect.center()); + if stand.clicked() { act = Some("stand"); } - if ui.button(crate::i18n::tr_label("Leave it")).clicked() { + let leave = ui.button(crate::i18n::tr_label("Leave it")); + crate::scenario::publish_handle("stand_up:leave", leave.rect.center()); + if leave.clicked() { act = Some("leave"); } - if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() { + let never = ui + .button(crate::i18n::tr_label("Don't ask again")) + .on_hover_text(crate::i18n::tr_label("Preferences › Design turns the offer back on")); + crate::scenario::publish_handle("stand_up:never", never.rect.center()); + if never.clicked() { act = Some("never"); } }); @@ -69,13 +83,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.set_status(e, true); } } - Some("leave") => { - app.stand_up = None; - app.stand_up_occurrence = None; - } + Some("leave") => leave(app), Some("never") => { - app.stand_up = None; - app.stand_up_occurrence = None; + leave(app); app.preferences.offer_stand_up = false; } _ => {} diff --git a/crates/ui-egui/tests/stand_up_offer.rs b/crates/ui-egui/tests/stand_up_offer.rs new file mode 100644 index 00000000..958afa60 --- /dev/null +++ b/crates/ui-egui/tests/stand_up_offer.rs @@ -0,0 +1,70 @@ +//! The "Stand this part up?" offer can be answered by handle (#1905): its buttons are published +//! as `stand_up:stand`, `stand_up:leave` and `stand_up:never`, it stays up through a camera move +//! and a command until answered, and `ui.prefs {"stand_up": false}` leaves the file as it is. +use serde_json::json; +use solidcraft_ui_egui::scenario::Harness; + +/// A harness with a 10 × 40 × 5 mm box (Y up) just opened from STEP: the offer is up. +fn opened() -> Harness { + let mut src = Harness::new(); + let path = src.dir.join("tall_in_y.step").to_string_lossy().into_owned(); + src.app.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap(); + src.app.run("file.export", json!({"path": path})).unwrap(); + let mut h = Harness::new(); + h.app.open_path(&path); + h.frames(3); + assert_eq!(h.app.stand_up.as_ref().map(Vec::len), Some(1), "the file's body is offered"); + h +} + +fn height(h: &Harness) -> f64 { + let b = solidcraft_engine::view::bounds(&h.app.session); + b.max.z - b.min.z +} + +#[test] +fn the_offer_buttons_are_handles() { + let mut h = opened(); + for name in ["stand_up:stand", "stand_up:leave", "stand_up:never"] { + assert!(h.at(&json!({"handle": name})).is_ok(), "{name} is published"); + } + // Unanswered, it stays up through a camera move and a command. + h.run(&json!([{"camera": {"yaw": 0.6, "pitch": 0.4}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}}])) + .unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_some(), "the offer waits for an answer"); + h.run(&json!([{"click": {"handle": "stand_up:stand"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!((height(&h) - 40.0).abs() < 1e-6, "stood up: {}", height(&h)); +} + +#[test] +fn leave_it_keeps_the_file_and_the_preference() { + let mut h = opened(); + h.run(&json!([{"click": {"handle": "stand_up:leave"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!(h.app.preferences.offer_stand_up); + assert!((height(&h) - 5.0).abs() < 1e-6); + assert!(h.at(&json!({"handle": "stand_up:leave"})).is_err(), "the offer is gone"); +} + +#[test] +fn dont_ask_again_turns_the_offer_off() { + let mut h = opened(); + h.run(&json!([{"click": {"handle": "stand_up:never"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!(!h.app.preferences.offer_stand_up); + assert!((height(&h) - 5.0).abs() < 1e-6); +} + +#[test] +fn prefs_stand_up_false_leaves_the_offer() { + let mut h = opened(); + let r = h.call("ui.prefs", json!({"stand_up": false})); + assert_eq!(r["result"]["stand_up_offer"], json!(null), "{r}"); + assert_eq!(r["result"]["offer_stand_up"], json!(true), "{r}"); + assert!((height(&h) - 5.0).abs() < 1e-6); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 13236705..36ccc8bf 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -152,7 +152,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | From 9c07bdd823b8bc61c7540f40df89e907d09553dc Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:10:18 -0400 Subject: [PATCH 112/149] Fonts: a Thai fallback, so Thai text no longer draws as boxes (#1913) Thanks @DuBearte. --- crates/ui-egui/src/fonts.rs | 89 +++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index b02fb892..1881f651 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -1,9 +1,10 @@ -//! System font loading and fallback setup for multi-language (CJK: Japanese, Chinese, Korean) support. +//! System font loading and fallback setup for multi-language (CJK: Japanese, Chinese, Korean; +//! Thai) support. //! -//! The UI's Latin font always comes first; a CJK font (then a Hangul font) is only ever appended as -//! a fallback, so English text renders exactly as before and only glyphs the Latin font lacks use -//! the CJK face. The Hangul fallback covers Korean where the CJK font found (Meiryo, Hiragino) -//! lacks Hangul. The order of the CJK and Chinese faces follows the UI language +//! The UI's Latin font always comes first; a CJK font (then a Hangul font, then a Thai font) is only +//! ever appended as a fallback, so English text renders exactly as before and only glyphs the Latin +//! font lacks use the CJK face. The Hangul fallback covers Korean where the CJK font found (Meiryo, +//! Hiragino) lacks Hangul; the Thai fallback covers Thai typed in a name or the agent chat (#1902). The order of the CJK and Chinese faces follows the UI language //! (see [`fallback_order`]): one face drawing a whole word keeps its glyphs on one baseline. use std::sync::Arc; @@ -44,9 +45,12 @@ pub fn with_ui_font(mut fonts: egui::FontDefinitions, font: egui::FontData) -> e fonts } -/// Name of the Hangul fallback entry in [`egui::FontDefinitions`], last. +/// Name of the Hangul fallback entry in [`egui::FontDefinitions`], after the CJK ones. pub const HANGUL_FALLBACK: &str = "hangul_fallback"; +/// Name of the Thai fallback entry in [`egui::FontDefinitions`], last (#1902). +pub const THAI_FALLBACK: &str = "thai_fallback"; + /// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only; /// add Chinese or Korean here together with a font that covers them. pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "ko"]; @@ -318,6 +322,32 @@ const HANGUL_FONT_CANDIDATES: &[&str] = &[ "/usr/share/fonts/nanum/NanumGothic.ttf", ]; +/// Thai fonts, appended last: none of the Latin or CJK faces draw Thai, so without one Thai text +/// shows as empty boxes (#1902). +#[cfg(not(target_arch = "wasm32"))] +const THAI_FONT_CANDIDATES: &[&str] = &[ + // Windows: Leelawadee UI (Windows 8 and later), Leelawadee, Tahoma + "C:\\Windows\\Fonts\\LeelawUI.ttf", + "C:\\Windows\\Fonts\\leelawad.ttf", + "C:\\Windows\\Fonts\\tahoma.ttf", + // macOS: Thonburi, Ayuthaya + "/System/Library/Fonts/Thonburi.ttc", + "/System/Library/Fonts/Supplemental/Thonburi.ttc", + "/System/Library/Fonts/Supplemental/Ayuthaya.ttf", + "/Library/Fonts/Thonburi.ttf", + // Linux: Noto Sans Thai, TLWG Loma and Garuda + "/usr/share/fonts/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", + "/usr/share/fonts/opentype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/truetype/tlwg/Loma.ttf", + "/usr/share/fonts/opentype/tlwg/Loma.otf", + "/usr/share/fonts/truetype/tlwg/Garuda.ttf", + "/usr/share/fonts/opentype/tlwg/Garuda.otf", + "/usr/share/fonts/tlwg/Loma.otf", +]; + /// Fonts covering simplified Chinese, appended after the CJK fallback so Chinese renders even /// when the CJK font found is a Japanese one without simplified characters (Hiragino Gothic, /// Meiryo, Yu Gothic). @@ -464,11 +494,16 @@ fn find_system_cjk_font() -> Option> { /// epaint centres each face's own line height in the row, so glyphs from two faces of very /// different height (Meiryo ≈ 2.0 em, Microsoft YaHei ≈ 1.4 em) sit at different heights /// inside one word. One face for the whole word keeps them on one baseline. -pub fn fallback_order(language: &str) -> [&'static str; 3] { - if language == "zh" { [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK] } else { [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK] } +/// Thai comes last whatever the language: no other face draws it. +pub fn fallback_order(language: &str) -> [&'static str; 4] { + if language == "zh" { + [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK] + } else { + [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK] + } } -/// Configure font definitions with system CJK, Chinese and Hangul fallbacks where available, +/// Configure font definitions with system CJK, Chinese, Hangul and Thai fallbacks where available, /// ordered for the UI language (see [`fallback_order`]), after the interface font `ui_font` /// when one is chosen (see [`ui_font`]). #[cfg(not(target_arch = "wasm32"))] @@ -476,6 +511,7 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let cjk = find_system_cjk_font(); let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes)); let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); + let thai = THAI_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); let mut fonts = egui::FontDefinitions::default(); // Set the fonts also when only the interface font changes, so going back to the default // takes the chosen one away. @@ -487,7 +523,8 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let bytes = match name { CJK_FALLBACK => cjk.clone(), CHINESE_FALLBACK => chinese.clone(), - _ => hangul.clone(), + HANGUL_FALLBACK => hangul.clone(), + _ => thai.clone(), }; if let Some(bytes) = bytes && let Some(with_fallback) = with_fallback(fonts.clone(), name, bytes) @@ -586,9 +623,13 @@ mod tests { #[test] fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() { - assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); + assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]); for lang in ["en", "ja", "ko", "de"] { - assert_eq!(fallback_order(lang), [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK], "{lang} keeps the Japanese font first"); + assert_eq!( + fallback_order(lang), + [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK], + "{lang} keeps the Japanese font first" + ); } let defaults = egui::FontDefinitions::default(); let mut fonts = defaults.clone(); @@ -598,10 +639,32 @@ mod tests { for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = &fonts.families[&family]; assert_eq!(list.first(), defaults.families[&family].first(), "the Latin UI font stays first"); - assert_eq!(&list[list.len() - 3..], [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); + assert_eq!(&list[list.len() - 4..], [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]); } } + /// Thai typed into the agent chat drew as empty boxes (#1902): every Thai candidate this + /// machine has must parse and cover Thai, and the fonts set up must then draw it. + #[test] + fn a_thai_fallback_draws_thai() { + let found: Vec> = THAI_FONT_CANDIDATES.iter().filter_map(|path| try_read_font(std::path::Path::new(path))).collect(); + for bytes in &found { + let face = ttf_parser::Face::parse(bytes, 0).unwrap(); + for c in "สวัสดี".chars() { + assert!(face.glyph_index(c).is_some(), "{c:?} is in the Thai font"); + } + } + let thai = |fonts: egui::FontDefinitions| { + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "สวัสดี")) + }; + assert!(!thai(egui::FontDefinitions::default()), "the built-in faces have no Thai"); + let Some(bytes) = found.into_iter().next() else { return }; + assert!(thai(with_fallback(egui::FontDefinitions::default(), THAI_FALLBACK, bytes).unwrap()), "Thai draws with the fallback"); + } + #[test] fn hangul_font_falls_back_after_the_cjk_font() { let defaults = egui::FontDefinitions::default(); From aa9119f922b42184fd303700451d77c8cd25d511 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:21 +0000 Subject: [PATCH 113/149] Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] --- THIRD-PARTY-LICENSES.txt | 121 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index a702b820..829f9393 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 545 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 550 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -24,6 +24,7 @@ approx 0.4.0 (Apache-2.0) arbitrary 1.5.0 (MIT OR Apache-2.0) arboard 3.6.1 (MIT OR Apache-2.0) array-macro 2.1.8 (MIT OR Apache-2.0) +arrayref 0.3.9 (BSD-2-Clause) arrayvec 0.7.8 (MIT OR Apache-2.0) as-raw-xcb-connection 1.0.1 (MIT OR Apache-2.0) ash 0.38.0+1.3.281 (MIT OR Apache-2.0) @@ -382,6 +383,7 @@ spade 2.15.1 (MIT OR Apache-2.0) spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0) stable_deref_trait 1.2.1 (MIT OR Apache-2.0) static_assertions 1.1.0 (MIT OR Apache-2.0) +strict-num 0.1.1 (MIT) subtle 2.6.1 (BSD-3-Clause) syn 1.0.109 (MIT OR Apache-2.0) syn 2.0.119 (MIT OR Apache-2.0) @@ -394,6 +396,8 @@ thiserror 2.0.21 (MIT OR Apache-2.0) thiserror-impl 1.0.69 (MIT OR Apache-2.0) thiserror-impl 2.0.21 (MIT OR Apache-2.0) tiff 0.11.3 (MIT) +tiny-skia 0.12.0 (BSD-3-Clause) +tiny-skia-path 0.12.0 (BSD-3-Clause) tinystr 0.8.4 (Unicode-3.0) tokio 1.53.2 (MIT) toml 1.1.8+spec-1.1.0 (MIT OR Apache-2.0) @@ -501,6 +505,7 @@ windows_x86_64_msvc 0.52.6 (MIT OR Apache-2.0) windows_x86_64_msvc 0.53.1 (MIT OR Apache-2.0) winit 0.30.13 (Apache-2.0) winnow 1.0.4 (MIT) +winreg 0.56.0 (MIT) wit-bindgen 0.57.1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) writeable 0.6.4 (Unicode-3.0) x11-dl 2.21.0 (MIT) @@ -2082,6 +2087,37 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +arrayref 0.3.9 +================================================================================ + +Copyright (c) 2015 David Roundy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ================================================================================ arrayvec 0.7.8 ================================================================================ @@ -12693,6 +12729,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +strict-num 0.1.1 +================================================================================ + +Copyright (c) 2022 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ subtle 2.6.1 ================================================================================ @@ -12795,6 +12855,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +tiny-skia 0.12.0, tiny-skia-path 0.12.0 +================================================================================ + +Copyright (c) 2011 Google Inc. All rights reserved. +Copyright (c) 2020 Yevhenii Reizner All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ================================================================================ tokio 1.53.2 ================================================================================ @@ -14757,6 +14852,30 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +winreg 0.56.0 +================================================================================ + +Copyright (c) 2015 Igor Shaula + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ x11rb 0.13.2, x11rb-protocol 0.13.2 ================================================================================ From f4ed34941021addc28cb197f2862b90b22767604 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Sat, 17 Oct 2026 10:35:56 +0200 Subject: [PATCH 114/149] README: link Send2Orca (SolidCraft to OrcaSlicer) (#1906) Thanks @tommasobbianchi. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4e2fb0bd..215caeac 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ rebuild with no errors, replayed here at 2× speed. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4) · [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).* +**Tools for SolidCraft:** [Send2Orca](https://github.com/tommasobbianchi/Send2Orca) sends the open design +(a window started with `--control`, through the MCP bridge) or a saved file to OrcaSlicer as STEP, 3MF or STL. + ## What works - **Current-view DXF export** from File → Export Current View as DXF, with hidden edges From ccd0b5aab8ddc14dc83a881c381242949c880676 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 17 Oct 2026 04:40:23 -0400 Subject: [PATCH 115/149] Robustness goldens: seeds 13, 71 and 94 build as main builds them, not as #853's branch did (#1929) #853 recorded seeds 13, 71 and 94 with the face and edge counts its own branch built (40/101, 19/50, 27/63). With main's kernel they still build as they did before #853 (41/103, 20/52, 26/61), so every full goldens run has failed since it landed. This records them again; #853's seed 20 change and the other 93 seeds are unchanged. Co-authored-by: Claude Opus 5.5 --- tests/robustness/goldens.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index ff06222b..ecfd5fba 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -14,7 +14,7 @@ "10": {"area":2809.248846,"bodies":1,"edges":39,"error":null,"faces":16,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut box","fillet","hole"],"unsupported":0,"volume":6696.329905}, "11": {"area":7858.300481,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","rectangular pattern","fillet"],"unsupported":0,"volume":34605.048259}, "12": {"area":4668.664174,"bodies":2,"edges":59,"error":null,"faces":25,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","chamfer","join cylinder","join box","cut cylinder","circular pattern"],"unsupported":3,"volume":11874.471611}, - "13": {"area":2101.521015,"bodies":1,"edges":101,"error":null,"faces":40,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939548}, + "13": {"area":2101.521015,"bodies":1,"edges":103,"error":null,"faces":41,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939546}, "14": {"area":10553.267409,"bodies":1,"edges":43,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","fillet"],"unsupported":0,"volume":10369.008764}, "15": {"area":2312.730168,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere","join cylinder"],"unsupported":0,"volume":5728.661237}, "16": {"area":8872.873884,"bodies":3,"edges":49,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":3,"steps":["box","join cylinder","circular pattern","hole"],"unsupported":0,"volume":44953.403148}, @@ -72,7 +72,7 @@ "68": {"area":7338.675126,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","hole","hole","rectangular pattern","rectangular pattern","fillet","circular pattern"],"unsupported":0,"volume":29780.741772}, "69": {"area":6451.122767,"bodies":1,"edges":60,"error":"offset: a corner whose faces don't meet the same way after t","faces":27,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join box","shell","shell"],"unsupported":0,"volume":4761.477515}, "70": {"area":3682.669157,"bodies":1,"edges":24,"error":"lumps: shell boundaries intersect or touch; cavity ownership","faces":12,"outcome":"failed","rejected":0,"shells":2,"steps":["extruded profile","join box","hole"],"unsupported":1,"volume":9155.027162}, - "71": {"area":3980.574721,"bodies":1,"edges":50,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927796}, + "71": {"area":3980.574721,"bodies":1,"edges":52,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927814}, "72": {"area":5770.270336,"bodies":2,"edges":44,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["box","join cylinder","hole","join box","join box","cut box","cut cylinder","cut cylinder"],"unsupported":0,"volume":16081.801492}, "73": {"area":6868.162117,"bodies":1,"edges":40,"error":"offset: a corner whose faces don't meet the same way after t","faces":18,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","join box","shell"],"unsupported":1,"volume":36461.939381}, "74": {"area":9294.57311,"bodies":1,"edges":18,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole"],"unsupported":0,"volume":55866.183074}, @@ -95,7 +95,7 @@ "91": {"area":3958.046518,"bodies":1,"edges":50,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","fillet"],"unsupported":0,"volume":9721.244548}, "92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292}, "93": {"area":7266.404017,"bodies":7,"edges":111,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171}, - "94": {"area":3480.70333,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, + "94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, "95": {"area":2425.131626,"bodies":1,"edges":19,"error":"boolean Cut: the operation failed: no result; the operation ","faces":9,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","hole","fillet","cut cylinder","cut sphere"],"unsupported":0,"volume":7106.301922} }, "notes": [ @@ -127,12 +127,13 @@ {"reason":"#1490: when the last-resort nudge fails both ways, two more screw axes are tried; seed 63's final box join (its face 0.00005 mm off the half-ring's cut face) now builds instead of being refused with an implausible volume, and the design goes on to its fillet","seeds":[63]}, {"reason":"a prism cut in a column round the tool (prism_local): seed 65's rectangular pattern (step 4, a second half-cylinder groove overlapping the first, in a block with a rounded vertical edge) is now built by the exact 2D route in a column clear of the round, where the B-rep tries left it with split edges. The step's part has the 10 faces, 24 edges and 16 vertices it should (28 edges and 18 vertices before), and its volume is 45145.383 mm³ against 45145.214 (exactly 45145.103, both within the mesh measure); the generator then picks other edges for its step 7 fillet, so the run ends on another design (45143.48 mm³, 15 faces)","seeds":[65]}, {"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]}, -{"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]}, + {"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]}, {"reason":"#726 (#1687): a full revolve of a profile off the axis comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs. Replaying each new design's commands on main and on this branch: seeds 45, 77 and 94 end the same; in seed 21 a box joined onto the ring's flat top now fuses with it, where main kept two touching shells that a later hole split into two bodies.","seeds":[21,45,77,94]}, {"reason":"#726 (#1718): a partial revolve made as a new body comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs (seed 24's shell now picks the centroid of a whole 270 deg annulus, which lies in the bore, so it is refused and the run goes on). Replaying each new design's commands on main and on this branch: 12 seeds end the same; in seeds 13 and 26 a box joined onto a flat revolved face now fuses with it (main kept two touching shells that a later step split into two bodies), seed 15's last cylinder join, which main refuses, now builds, and seed 78's cut box no longer leaves 87.5 mm² of extra area (the end cap's); seed 63 refuses the same fillet on both and ends with nearly the same body. Seed 9 now ends ok instead of failing, but its new design ends the same on main. No seed gains a failure.","seeds":[9,10,13,15,17,19,22,24,25,26,30,63,75,76,78,90,91]}, {"reason":"Current main replay: seed 55 now meshes every face and passes validity; edge-consistent measuring meshes (#1753) change its measured volume from 24741.239331 to 24740.927259 mm3. Seed 71 joins with one fewer redundant face and two fewer edges (19/50); its five steps, successful outcome, volume and area are unchanged within the existing 1e-6 tolerances. Both fixed command sequences were replayed with validity checks after every step.","seeds":[55,71]}, {"reason":"Seed 20: trace conical cutters against earlier conical cuts at finer unshifted tolerances before coarse copies can leave unusable junctions. The circular pattern and final box cut both build; replayed in the app with no feature errors, unchanged 45 faces/116 edges/one shell, no tool nudge, and volume/spatial guards retained. The finer intersection curves change measured volume by 0.020503 mm3 and area by 0.171629 mm2.","seeds":[20]}, {"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]}, - {"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]} + {"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]}, + {"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]} ] } From e49770bd2418c57bb17ac9c4fa1c2659f7cb8173 Mon Sep 17 00:00:00 2001 From: potuzai Date: Sat, 17 Oct 2026 10:47:05 +0200 Subject: [PATCH 116/149] Add Czech (cs) UI translation (#1752) Thanks @potuzai. --- assets/locales/cs.json | 1480 +++++++++++++++++++++ crates/ui-egui/src/colour_vision_tests.rs | 1 + crates/ui-egui/src/i18n.rs | 7 +- 3 files changed, 1486 insertions(+), 2 deletions(-) create mode 100644 assets/locales/cs.json diff --git a/assets/locales/cs.json b/assets/locales/cs.json new file mode 100644 index 00000000..619f6194 --- /dev/null +++ b/assets/locales/cs.json @@ -0,0 +1,1480 @@ +{ + "0 turns it off": "0 vypíná", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Otevřete níže koncept na GitHubu a vložte hlášení zkopírované do schránky.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Otevřete níže koncept na GitHubu. Název a hlášení se vyplní automaticky.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Přiložte snímek obrazovky: zkopírujte a vložte jej, nebo uložte a nahrajte PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Hlášení odešlete na GitHubu tlačítkem Submit new issue.", + "A new configuration, a copy of the active one": "Nová konfigurace, kopie aktivní", + "AI": "AI", + "About SolidCraft": "O aplikaci SolidCraft", + "Active": "Aktivní", + "Active rule": "Aktivní pravidlo", + "Add": "Přidat", + "Add Key": "Přidat klíč", + "Add Mid Radius": "Přidat střední poloměr", + "Add Row": "Přidat řádek", + "Add condition": "Přidat podmínku", + "Alignment": "Zarovnání", + "All Sketch Points": "Všechny body náčrtu", + "All open designs": "Všechny otevřené návrhy", + "Allowed expressions, separated by semicolons": "Povolené výrazy oddělené středníkem", + "Also deletes": "Smaže také", + "Angle 2": "Úhel 2", + "Animate": "Animovat", + "Appearance name": "Název vzhledu", + "Applied Radius": "Použitý poloměr", + "Apply To": "Použít na", + "Apply to selection": "Použít na výběr", + "Arc length": "Délka oblouku", + "Area": "Plocha", + "Attachment": "Připojení", + "BROWSER": "PROHLÍŽEČ", + "Bend Line Position": "Poloha ohybové čáry", + "Bend Position": "Poloha ohybu", + "Bend Radius": "Poloměr ohybu", + "Bends": "Ohyby", + "Body": "Těleso", + "Boundary": "Hranice", + "Bounding box": "Ohraničující kvádr", + "CSV or JSON: updates existing parameters, adds new ones": "CSV nebo JSON: aktualizuje existující parametry, přidá nové", + "Capture All Positions": "Zachytit všechny polohy", + "Catch Direction": "Směr záchytu", + "Cells": "Buňky", + "Center X": "Střed X", + "Center Y": "Střed Y", + "Center of mass": "Těžiště", + "Centre to centre": "Střed–střed", + "Chamfer Type": "Typ sražení", + "Check collisions": "Kontrolovat kolize", + "Checked": "Zaškrtnuto", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Zvolte novou rovinu. Geometrie a kóty náčrtu zůstanou ve svých dvourozměrných souřadnicích beze změny.", + "Circumference": "Obvod", + "Close draft": "Zavřít koncept", + "Collisions": "Kolize", + "Colour": "Barva", + "Component1": "Komponenta1", + "Configuration": "Konfigurace", + "Configuration rules": "Pravidla konfigurace", + "Construction": "Konstrukční", + "Contact": "Kontakt", + "Continuity": "Spojitost", + "Copy Error Message": "Kopírovat chybovou zprávu", + "Copy Warning Message": "Kopírovat varování", + "Copy all licences": "Kopírovat všechny licence", + "Copy diagnostic information": "Kopírovat diagnostické informace", + "Copy report": "Kopírovat hlášení", + "Copy screenshot": "Kopírovat snímek", + "Corner Relief": "Odlehčení rohu", + "Corner Relief Shape": "Tvar odlehčení rohu", + "Counterbore depth": "Hloubka zahloubení", + "Countersink angle": "Úhel kuželového zahloubení", + "Create Issue — review draft": "Vytvořit hlášení — kontrola konceptu", + "Cube": "Krychle", + "Current orthographic view (1:1 mm)": "Aktuální pravoúhlý pohled (1:1 mm)", + "Current perspective view": "Aktuální perspektivní pohled", + "Curvature (G2)": "Křivost (G2)", + "Customize Toolbar": "Přizpůsobit panel nástrojů", + "Delete all": "Smazat vše", + "Delete rule": "Smazat pravidlo", + "Delete the row": "Smazat řádek", + "Delete the set": "Smazat sadu", + "Delete this parameter": "Smazat tento parametr", + "Density": "Hustota", + "Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.", + "Discard": "Zahodit", + "Do not export hidden edges": "Neexportovat skryté hrany", + "Double-click to rename": "Dvojklikem přejmenujete", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dvojklik prostředním tlačítkem v pohledu: Domů (výchozí pohled se zobrazením všeho), Přizpůsobit (zobrazí vše při zachování natočení) nebo nic", + "Drive Joint": "Pohánět kloub", + "Edges": "Hrany", + "Edit Profile Sketch": "Upravit náčrt profilu", + "Enable rule": "Povolit pravidlo", + "Enabled": "Povoleno", + "Explode": "Rozložit", + "Export Animation…": "Exportovat animaci…", + "Export Current View as DXF": "Exportovat aktuální pohled do DXF", + "Export Current View as DXF…": "Exportovat aktuální pohled do DXF…", + "Export DXF…": "Exportovat DXF…", + "Export hidden edges as dashed lines": "Exportovat skryté hrany čárkovaně", + "Extents": "Rozsahy", + "Face": "Plocha", + "Face attachment lost": "Připojení k ploše ztraceno", + "Faces": "Plochy", + "Faces / edges / vertices": "Plochy / hrany / vrcholy", + "Favourite": "Oblíbené", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Soubory z mnoha CAD programů mají osu Y nahoru, proto se zde otevřou ležící na zádech (Z nahoru).", + "Filter": "Filtr", + "Find in Browser": "Najít v prohlížeči", + "Flat": "Plochý", + "Flat size": "Rozměr rozvinu", + "Follow camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)", + "Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)", + "Font": "Písmo", + "Full Length": "Celá délka", + "Gap": "Mezera", + "Grid": "Mřížka", + "Height": "Výška", + "Hidden edges": "Skryté hrany", + "Holes": "Otvory", + "Holes (edges on them)": "Otvory (jejich hrany)", + "Home view": "Výchozí pohled", + "In This Design": "V tomto návrhu", + "In place (surfaces)": "Na místě (plochy)", + "Inside": "Uvnitř", + "Interferes": "Koliduje", + "Issue title": "Název hlášení", + "Joint Origin1": "Počátek kloubu1", + "Keep Tools": "Ponechat nástroje", + "Later": "Později", + "Leave it": "Nechat být", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Starší typ připojení k ploše. Novým navázáním na ověřenou plochu vznikne přesné připojení.", + "Length": "Délka", + "Length units": "Jednotky délky", + "Letter Spacing": "Mezery mezi písmeny", + "Library": "Knihovna", + "Line Spacing": "Řádkování", + "Loop": "Smyčka", + "Margin": "Okraj", + "Mass": "Hmotnost", + "Material": "Materiál", + "Max": "Max", + "Mid Radius": "Střední poloměr", + "Middle double-click": "Dvojklik prostředním tlačítkem", + "Min": "Min", + "Mitre": "Pokos", + "Modeled": "Modelovaný", + "More toolbar panels": "Další panely nástrojů", + "Motion": "Pohyb", + "Move Type": "Typ posunu", + "Move earlier": "Posunout dříve", + "Move later": "Posunout později", + "Name": "Název", + "No crash reports.": "Žádná hlášení o pádech.", + "No matching appearances": "Žádné odpovídající vzhledy", + "No visible bodies to export.": "Žádná viditelná tělesa k exportu.", + "None": "Žádné", + "Not available yet": "Zatím není k dispozici", + "Notch": "Zářez", + "Opacity": "Neprůhlednost", + "Open GitHub draft": "Otevřít koncept na GitHubu", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametrický 3D CAD v jazyce Rust. Licence MIT OR Apache-2.0.", + "Other side": "Druhá strana", + "Outside": "Vně", + "Overall length": "Celková délka", + "Parameter values": "Hodnoty parametrů", + "Pass through": "Procházet skrz", + "Path": "Cesta", + "Per Edge": "Pro každou hranu", + "Pick the tool bodies": "Vyberte nástrojová tělesa", + "Picked": "Vybráno", + "Place at the origin instead": "Místo toho umístit do počátku", + "Playback": "Přehrávání", + "Position": "Poloha", + "Position (0 to 1)": "Poloha (0 až 1)", + "Position (G0)": "Poloha (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Poloha podél hrany, 0 na počátečním konci, 1 na druhém", + "Preview Row": "Náhled řádku", + "Preview leaves the current design unchanged.": "Náhled aktuální návrh nemění.", + "Profiles/Curves": "Profily/křivky", + "Projection": "Promítání", + "RECENT": "NEDÁVNÉ", + "RULED SURFACE": "PŘÍMKOVÁ PLOCHA", + "Radius Fit": "Proložení poloměrem", + "Ratio": "Poměr", + "Reattach selected face": "Znovu připojit k vybrané ploše", + "Recover": "Obnovit", + "Recover unsaved design?": "Obnovit neuložený návrh?", + "Rectangular": "Obdélníkový", + "Reference face": "Referenční plocha", + "Remove Tools": "Odstranit nástroje", + "Remove from list": "Odebrat ze seznamu", + "Remove override": "Odebrat přepsání", + "Remove private design details or paths before sharing.": "Před sdílením odstraňte soukromé údaje o návrhu a cesty k souborům.", + "Remove screenshot": "Odebrat snímek", + "Remove the column": "Odebrat sloupec", + "Remove the track": "Odebrat stopu", + "Remove this point": "Odebrat tento bod", + "Report a bug — review a screenshot and open a GitHub draft": "Nahlásit chybu — zkontrolovat snímek a otevřít koncept na GitHubu", + "Reset Toolbar": "Obnovit panel nástrojů", + "Restore outer boundary": "Obnovit vnější hranici", + "Restore the inherited component or physical material appearance": "Obnovit zděděný vzhled komponenty nebo fyzického materiálu", + "Result": "Výsledek", + "Retake screenshot": "Pořídit snímek znovu", + "Reverse": "Obrátit", + "Reverse Direction": "Obrátit směr", + "Revert": "Vrátit", + "Revert All Positions": "Vrátit všechny polohy", + "Revert Position": "Vrátit polohu", + "Review the report and screenshot before opening GitHub.": "Před otevřením GitHubu zkontrolujte hlášení a snímek.", + "Rim Edge": "Okrajová hrana", + "Roll History Marker Here": "Přesunout značku historie sem", + "Round": "Zaoblený", + "Rule": "Pravidlo", + "Save Rule": "Uložit pravidlo", + "Save the study as an animated GIF, seen as the viewport shows it": "Uložit studii jako animovaný GIF tak, jak ji zobrazuje pohled", + "Saved preview. Geometry is being checked before editing.": "Uložený náhled. Geometrie se před úpravou kontroluje.", + "Scale factor": "Měřítko", + "Search": "Hledat", + "Search commands": "Hledat příkazy", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Vybrat jiné: podržte levé tlačítko na modelu (nebo pravé tlačítko › Vybrat jiné…) a zobrazí se vše pod kurzorem; dalším kliknutím na stejné místo vyberete další prvek. Dvojklikem prostředním tlačítkem přejdete na výchozí pohled (Předvolby › Navigace).", + "Select bodies (a face picks its body)": "Vyberte tělesa (plocha vybere své těleso)", + "Select bodies or faces instead": "Vyberte místo toho tělesa nebo plochy", + "Selection filter": "Filtr výběru", + "Semantic faces share their appearance across instances.": "Sémantické plochy sdílejí vzhled napříč instancemi.", + "Send your report on GitHub": "Odeslat hlášení na GitHub", + "Shape": "Tvar", + "Show Agent Cursor": "Zobrazit kurzor agenta", + "Show in folder": "Zobrazit ve složce", + "Show the pointer an AI agent moves (#35)": "Zobrazit ukazatel, kterým pohybuje AI agent (#35)", + "Signed axes": "Osy se znaménkem", + "Size": "Velikost", + "Sketch constraints": "Vazby náčrtu", + "Sketch curves": "Křivky náčrtu", + "Sketch dimensions": "Kóty náčrtu", + "Sketch geometry and profiles": "Geometrie a profily náčrtu", + "Sketch plane has no body-face attachment.": "Rovina náčrtu není připojena k ploše tělesa.", + "Sketch points": "Body náčrtu", + "Slot width": "Šířka drážky", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.", + "Speed": "Rychlost", + "Stand it up": "Postavit", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postavit: otočení o čtvrt otáčky kolem osy X jako jeden prvek Posunutí, který lze vrátit.", + "Stand this part up?": "Postavit tento díl?", + "Step": "Krok", + "Steps": "Kroky", + "Style": "Styl", + "Suppressed": "Potlačeno", + "Swap Ends": "Prohodit konce", + "Tangent (G1)": "Tečně (G1)", + "Tangent Chain": "Tečný řetězec", + "Tear": "Roztržení", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Čárkovaný náčrt je uložené vodítko roviny. Pro opravu připojení vyberte novou plochu.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Uložený výskyt autora chybí. Před novým navázáním vyberte platné umístění autora.", + "Then": "Pak", + "These features will be deleted:": "Tyto prvky budou smazány:", + "These features will fail:": "Tyto prvky selžou:", + "Thickness Outside": "Tloušťka vně", + "Thin": "Tenkostěnné", + "Thin Extrude": "Tenkostěnné vysunutí", + "This child is shared. Deleting it removes it from every copy of its parent.": "Tento potomek je sdílený. Smazáním jej odeberete ze všech kopií jeho rodiče.", + "This child is shared. Position changes apply to every copy of its parent.": "Tento potomek je sdílený. Změny polohy se projeví ve všech kopiích jeho rodiče.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tato plocha nemůže poskytnout trvalé připojení. Zvolte jinou plochu nebo výslovně použijte pevnou rovinu.", + "To Offset": "Odsazení k objektu", + "Tolerance": "Tolerance", + "Turn off section analysis before exporting the view.": "Před exportem pohledu vypněte analýzu řezu.", + "Type": "Typ", + "UNTRIM": "ZRUŠIT OŘEZ", + "Updating the preview": "Aktualizace náhledu", + "Use active author placement": "Použít aktivní umístění autora", + "Use fixed plane": "Použít pevnou rovinu", + "Use saved fixed plane": "Použít uloženou pevnou rovinu", + "Use selected face as fixed plane": "Použít vybranou plochu jako pevnou rovinu", + "VIEW": "POHLED", + "Value": "Hodnota", + "Vertices": "Vrcholy", + "View navigator": "Navigátor pohledu", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Pouze viditelná tělesa. Křivky jsou nahrazeny úsečkami (tolerance 0,01 mm).", + "Volume": "Objem", + "Wall": "Stěna", + "Wall Location": "Umístění stěny", + "Wall Thickness": "Tloušťka stěny", + "When": "Když", + "Width": "Šířka", + "Will fail": "Selže", + "angle, height": "úhel, výška", + "appearance.dielectric_ior": "Index lomu dielektrika", + "appearance.metallic": "Kovovost", + "appearance.pbr_help": "Tento povrch zobrazíte v renderu nebo v produktovém PNG. Bez lomu a průchodu světla.", + "appearance.pbr_opaque": "Neprůhledné PBR", + "appearance.roughness": "Drsnost", + "appearance.surface_response": "Odezva povrchu", + "browser.body_name": "Těleso{number}", + "browser.component_name": "Komponenta{number}", + "browser.parameters": "Parametry ({count})", + "browser.precision": "Přesnost: {precision}", + "browser.sketch_name": "Náčrt{number}", + "browser.units": "Jednotky: {units}", + "cam.flat": "Ploché čelo", + "cam.follower": "Zdvihátko", + "cam.knife": "Břit", + "cam.needs_joints": "vyžaduje jeden otočný kloub a jeden posuvný nebo kývavý", + "cam.roller": "Kladka", + "cam.roller_pick": "Kladka (volitelné)", + "cam.tip_pick": "Hrot (volitelné)", + "cmd.appearance_assign": "Vzhled", + "cmd.appearance_library": "Knihovna vzhledů", + "cmd.appearance_list": "Seznam vzhledů", + "cmd.body_rename": "Přejmenovat těleso", + "cmd.browser_group": "Nová skupina", + "cmd.browser_move": "Přesunout položky prohlížeče", + "cmd.browser_order": "Seřadit položky prohlížeče", + "cmd.browser_rename_group": "Přejmenovat skupinu", + "cmd.browser_ungroup": "Zrušit skupinu", + "cmd.browser_visibility": "Zobrazit/skrýt", + "cmd.canvas_calibrate": "Kalibrovat plátno", + "cmd.canvas_decal": "Obtisk", + "cmd.canvas_delete": "Smazat plátno", + "cmd.canvas_edit": "Upravit plátno", + "cmd.canvas_insert": "Plátno", + "cmd.component_activate": "Aktivovat komponentu", + "cmd.component_break_link": "Zrušit odkaz", + "cmd.component_capture_position": "Zachytit polohu", + "cmd.component_create": "Nová komponenta", + "cmd.component_delete": "Smazat komponentu", + "cmd.component_from_bodies": "Vytvořit komponenty z těles", + "cmd.component_insert_design": "Vložit návrh", + "cmd.component_links": "Seznam odkazů", + "cmd.component_list": "Seznam komponent", + "cmd.component_move_bodies": "Přesunout tělesa do komponenty", + "cmd.component_move_sketches": "Přesunout náčrty do komponenty", + "cmd.component_paste_new": "Vložit jako nové", + "cmd.component_properties": "Fyzikální vlastnosti komponenty", + "cmd.component_rename": "Přejmenovat komponentu", + "cmd.component_revert_position": "Vrátit polohu", + "cmd.component_unlock": "Odemknout obsah", + "cmd.component_update_links": "Aktualizovat odkazy", + "cmd.config_activate": "Aktivovat konfiguraci", + "cmd.config_column": "Sloupec konfigurace", + "cmd.config_configure": "Konfigurovat", + "cmd.config_preview": "Náhled konfigurace", + "cmd.config_row": "Řádek konfigurace", + "cmd.config_rule": "Pravidlo konfigurace", + "cmd.config_rules": "Pravidla konfigurace", + "cmd.config_table": "Zobrazit tabulku konfigurací", + "cmd.construct_axis_edge": "Osa hranou", + "cmd.construct_axis_normal_to_face": "Osa kolmá k ploše", + "cmd.construct_axis_through_cylinder": "Osa válce/kužele/anuloidu", + "cmd.construct_axis_two_planes": "Osa průsečíkem dvou rovin", + "cmd.construct_axis_two_points": "Osa dvěma body", + "cmd.construct_plane_along_path": "Rovina podél cesty", + "cmd.construct_plane_midplane": "Střední rovina", + "cmd.construct_plane_perpendicular": "Kolmá rovina", + "cmd.construct_plane_tangent": "Tečná rovina", + "cmd.construct_plane_three_points": "Rovina třemi body", + "cmd.construct_plane_two_edges": "Rovina dvěma hranami", + "cmd.construct_point_along_path": "Bod podél cesty", + "cmd.construct_point_center": "Bod ve středu kružnice/koule/anuloidu", + "cmd.construct_point_edge_plane": "Bod v průsečíku hrany a roviny", + "cmd.construct_point_three_planes": "Bod průsečíkem tří rovin", + "cmd.construct_point_two_edges": "Bod průsečíkem dvou hran", + "cmd.construct_point_vertex": "Bod ve vrcholu", + "cmd.contact_check": "Kontrola kontaktu", + "cmd.contact_create": "Nová sada kontaktů", + "cmd.contact_disable_all": "Vypnout kontakt", + "cmd.contact_edit": "Upravit sadu kontaktů", + "cmd.contact_enable_all": "Zapnout všechny kontakty", + "cmd.contact_enable_sets": "Zapnout sady kontaktů", + "cmd.contact_list": "Seznam sad kontaktů", + "cmd.diagnostics_crash_report": "Hlášení o pádu", + "cmd.diagnostics_crash_reports": "Hlášení o pádech", + "cmd.diagnostics_crash_reports_clear": "Smazat hlášení o pádech", + "cmd.diagnostics_crash_test": "Test pádu", + "cmd.doc_recover": "Obnovit návrh", + "cmd.doc_recovery_discard": "Zahodit obnovený návrh", + "cmd.doc_recovery_list": "Obnovitelné návrhy", + "cmd.document_inspect": "Prozkoumat návrh", + "cmd.document_metadata": "Vlastnosti návrhu", + "cmd.document_metadata_remove": "Odebrat vlastnost návrhu", + "cmd.document_metadata_set": "Změnit vlastnosti návrhu", + "cmd.document_units": "Změnit jednotky návrhu", + "cmd.edit_checkpoint": "Kontrolní bod", + "cmd.edit_restore_checkpoint": "Obnovit kontrolní bod", + "cmd.engine_commands": "Seznam příkazů", + "cmd.explode_create": "Rozložený pohled", + "cmd.explode_delete": "Smazat rozložený pohled", + "cmd.explode_list": "Seznam rozložených pohledů", + "cmd.explode_show": "Zobrazit rozložený pohled", + "cmd.expr_evaluate": "Vyhodnotit výraz", + "cmd.face_delete": "Smazat plochu", + "cmd.file_export": "Exportovat", + "cmd.file_insert_mesh": "Vložit síť", + "cmd.file_insert_step": "Vložit STEP", + "cmd.file_save_mesh": "Uložit jako síť", + "cmd.file_slicers": "Nainstalované slicery", + "cmd.inspect_accessibility": "Analýza přístupnosti", + "cmd.inspect_center_of_mass": "Těžiště", + "cmd.inspect_compare": "Porovnat tělesa", + "cmd.inspect_continuity": "Analýza spojitosti", + "cmd.inspect_curvature_comb": "Analýza hřebenu křivosti", + "cmd.inspect_curvature_map": "Mapa křivosti", + "cmd.inspect_draft": "Analýza úkosu", + "cmd.inspect_environment_map": "Analýza mapou prostředí", + "cmd.inspect_interference": "Kolize", + "cmd.inspect_isocurve": "Analýza izokřivek", + "cmd.inspect_minimum_radius": "Analýza minimálního poloměru", + "cmd.inspect_zebra": "Zebra analýza", + "cmd.joint_as_built": "Kloub podle stavu", + "cmd.joint_cam": "Vačka", + "cmd.joint_create": "Kloub", + "cmd.joint_delete": "Smazat kloub", + "cmd.joint_drive": "Pohánět klouby", + "cmd.joint_edit": "Upravit kloub", + "cmd.joint_gear": "Ozubený převod", + "cmd.joint_limits": "Limity kloubu", + "cmd.joint_list": "Seznam kloubů", + "cmd.joint_motion_link": "Vazba pohybu", + "cmd.joint_origin": "Počátek kloubu", + "cmd.joint_rack_pinion": "Hřeben a pastorek", + "cmd.joint_reach": "Dosáhnout bodu", + "cmd.joint_rigid_group": "Pevná skupina", + "cmd.joint_screw": "Šroubový pohyb", + "cmd.joint_solve": "Vyřešit klouby", + "cmd.library_apply": "Použít položku místní knihovny", + "cmd.library_export": "Exportovat knihovnu návrhu", + "cmd.library_inspect": "Prozkoumat místní knihovnu", + "cmd.material_assign": "Fyzický materiál", + "cmd.material_create": "Nový fyzický materiál", + "cmd.material_delete": "Smazat fyzický materiál", + "cmd.material_edit": "Upravit fyzický materiál", + "cmd.material_list": "Seznam materiálů", + "cmd.material_properties_directional": "Směrový modul pružnosti", + "cmd.material_properties_validate": "Ověřit data fyzického materiálu", + "cmd.mesh_convert": "Převést síť", + "cmd.model_edges": "Seznam hran", + "cmd.model_faces": "Seznam ploch", + "cmd.model_threads": "Seznam závitů", + "cmd.motion_delete": "Smazat studii pohybu", + "cmd.motion_export": "Exportovat studii pohybu", + "cmd.motion_export_animation": "Exportovat animaci pohybu", + "cmd.motion_list": "Seznam studií pohybu", + "cmd.motion_play": "Přehrát studii pohybu", + "cmd.motion_study": "Studie pohybu", + "cmd.motion_trace": "Stopa pohybu", + "cmd.motion_trace_keep": "Ponechat stopu pohybu", + "cmd.occurrence_copy": "Vložit (instance)", + "cmd.occurrence_ground": "Ukotvit", + "cmd.occurrence_move": "Přesunout výskyt", + "cmd.occurrence_properties": "Fyzikální vlastnosti výskytu", + "cmd.parameters_add": "Přidat uživatelský parametr", + "cmd.parameters_change": "Změnit parametry", + "cmd.parameters_comment": "Komentář parametru", + "cmd.parameters_complete": "Doplnit výraz", + "cmd.parameters_delete": "Smazat parametr", + "cmd.parameters_export": "Exportovat parametry", + "cmd.parameters_favorite": "Oblíbený parametr", + "cmd.parameters_graph": "Graf parametrů", + "cmd.parameters_import": "Importovat parametry", + "cmd.parameters_list": "Seznam parametrů", + "cmd.parameters_move": "Přesunout parametr", + "cmd.parameters_rename": "Přejmenovat parametr", + "cmd.parameters_users": "Kde se parametr používá", + "cmd.parts_fastener": "Vložit spojovací prvek", + "cmd.parts_insert": "Vložit díl", + "cmd.parts_library": "Normalizované díly", + "cmd.parts_resize": "Změnit velikost dílu", + "cmd.plastic_assign_rule": "Přiřadit pravidlo pro plasty", + "cmd.plastic_boss": "Nálitek", + "cmd.plastic_lip": "Osazení (lem)", + "cmd.plastic_manage_rules": "Spravovat pravidla pro plasty", + "cmd.plastic_rest": "Opěrka", + "cmd.plastic_rules": "Seznam pravidel pro plasty", + "cmd.plastic_snap_fit": "Zacvakávací spoj", + "cmd.render_backends": "Renderovací jádra", + "cmd.render_cancel": "Zrušit render", + "cmd.render_export": "Exportovat obrázek renderu", + "cmd.render_preflight": "Zkontrolovat požadavek na render", + "cmd.render_scene": "Prozkoumat scénu renderu", + "cmd.render_settings_apply": "Použít kameru renderu", + "cmd.render_settings_get": "Nastavení renderu", + "cmd.render_settings_set": "Uložit nastavení renderu", + "cmd.render_start": "Renderovat obrázek", + "cmd.render_status": "Průběh renderu", + "cmd.select_clear": "Zrušit výběr", + "cmd.sheet_convert": "Převést na plech", + "cmd.sheet_export_bend_table": "Exportovat tabulku ohybů", + "cmd.sheet_export_dxf": "Exportovat rozvin do DXF", + "cmd.sheet_flange": "Příruba", + "cmd.sheet_flat_pattern": "Vytvořit rozvin", + "cmd.sheet_fold": "Ohnout", + "cmd.sheet_hem": "Lem", + "cmd.sheet_manage_rules": "Pravidla pro plech", + "cmd.sheet_perforate": "Perforovat", + "cmd.sheet_refold": "Znovu ohnout", + "cmd.sheet_rules": "Seznam pravidel pro plech", + "cmd.sheet_unfold": "Rozvinout", + "cmd.sketch_arc_center_point": "Oblouk ze středu", + "cmd.sketch_arc_tangent": "Tečný oblouk", + "cmd.sketch_arc_three_point": "Oblouk ze 3 bodů", + "cmd.sketch_auto_constrain": "Automatické vazby", + "cmd.sketch_auto_constrain_from_datum": "Automatické vazby od reference", + "cmd.sketch_auto_project": "Automaticky promítat hrany na referenci", + "cmd.sketch_blend_curve": "Přechodová křivka", + "cmd.sketch_chain": "Řetězec", + "cmd.sketch_chamfer_distance_angle": "Sražení vzdáleností a úhlem", + "cmd.sketch_chamfer_equal_distance": "Sražení stejnou vzdáleností", + "cmd.sketch_chamfer_two_distance": "Sražení dvěma vzdálenostmi", + "cmd.sketch_circle_center": "Kružnice ze středu", + "cmd.sketch_circle_three_point": "Kružnice ze 3 bodů", + "cmd.sketch_circle_three_tangent": "Kružnice tečná ke 3 prvkům", + "cmd.sketch_circle_two_point": "Kružnice ze 2 bodů", + "cmd.sketch_circle_two_tangent": "Kružnice tečná ke 2 prvkům", + "cmd.sketch_constrainer": "Zavazbení náčrtu", + "cmd.sketch_constraint_fix": "Zafixovat/uvolnit", + "cmd.sketch_constraint_midpoint": "Střed", + "cmd.sketch_construction": "Běžná/konstrukční", + "cmd.sketch_copy": "Kopírovat prvky náčrtu", + "cmd.sketch_cut": "Vyjmout prvky náčrtu", + "cmd.sketch_delete": "Smazat prvky náčrtu", + "cmd.sketch_dimension_text": "Přesunout text kóty", + "cmd.sketch_drag": "Táhnout geometrii náčrtu", + "cmd.sketch_drag_end": "Ukončit tažení náčrtu", + "cmd.sketch_draw_dimensioned": "Kreslit okótovanou geometrii náčrtu", + "cmd.sketch_edit": "Upravit náčrt", + "cmd.sketch_edit_text": "Upravit text", + "cmd.sketch_export_dxf": "Uložit jako DXF", + "cmd.sketch_finish_auto_constrain": "Dokončit s automatickými vazbami", + "cmd.sketch_fit_curves_to_section": "Proložit křivky řezem sítě", + "cmd.sketch_glyphs": "Symboly vazeb", + "cmd.sketch_include_3d": "Zahrnout 3D geometrii", + "cmd.sketch_infinite": "Nekonečná přímka", + "cmd.sketch_insert_dxf": "Vložit DXF", + "cmd.sketch_insert_svg": "Vložit SVG", + "cmd.sketch_inspect": "Prozkoumat náčrt", + "cmd.sketch_intersection_curve": "Průsečná křivka", + "cmd.sketch_isoparametric_curve": "Izoparametrická křivka", + "cmd.sketch_line3d": "3D úsečka", + "cmd.sketch_line_midpoint": "Úsečka od středu", + "cmd.sketch_move": "Přesunout/kopírovat prvky náčrtu", + "cmd.sketch_move3d": "Přesunout 3D bod", + "cmd.sketch_move_point": "Táhnout bod náčrtu", + "cmd.sketch_offset_break": "Zrušit vazbu odsazení", + "cmd.sketch_options": "Možnosti palety náčrtu", + "cmd.sketch_paste": "Vložit prvky náčrtu", + "cmd.sketch_point3d": "3D bod", + "cmd.sketch_polygon_circumscribed": "Opsaný mnohoúhelník", + "cmd.sketch_polygon_edge": "Mnohoúhelník hranou", + "cmd.sketch_polygon_inscribed": "Vepsaný mnohoúhelník", + "cmd.sketch_project_to_surface": "Promítnout na plochu", + "cmd.sketch_rectangle_center": "Obdélník ze středu", + "cmd.sketch_rectangle_rounded": "Zaoblený obdélník", + "cmd.sketch_rectangle_three_point": "Obdélník ze 3 bodů", + "cmd.sketch_rectangle_two_point": "Obdélník ze 2 bodů", + "cmd.sketch_redefine": "Změnit rovinu náčrtu", + "cmd.sketch_slot_arc_center": "Oblouková drážka ze středu", + "cmd.sketch_slot_arc_three_point": "Oblouková drážka ze 3 bodů", + "cmd.sketch_slot_center_point": "Drážka ze středu", + "cmd.sketch_slot_center_to_center": "Drážka střed–střed", + "cmd.sketch_slot_overall": "Drážka celkovou délkou", + "cmd.sketch_snap": "Přichytávání", + "cmd.sketch_solve": "Vyřešit náčrt", + "cmd.sketch_spline3d": "3D splajn", + "cmd.sketch_spline_control_point": "Splajn řídicími body", + "cmd.sketch_spline_control_point_5": "Splajn řídicími body (stupeň 5)", + "cmd.sketch_spline_fit_point": "Splajn procházející body", + "cmd.sketch_spun_profile": "Rotační obrys", + "cmd.sketch_toggle_driven": "Přepnout řízenou kótu", + "cmd.solid_align": "Zarovnat", + "cmd.solid_boundary_fill": "Vyplnění hranicemi", + "cmd.solid_bounding_solid": "Obalové těleso", + "cmd.solid_emboss": "Reliéf", + "cmd.solid_move": "Posunout/kopírovat", + "cmd.solid_offset_face": "Odsadit plochu", + "cmd.solid_press_pull": "Tlačit/táhnout", + "cmd.solid_remove": "Odstranit", + "cmd.solid_replace_face": "Nahradit plochu", + "cmd.solid_rib": "Žebro", + "cmd.solid_split_face": "Rozdělit plochu", + "cmd.solid_web": "Výztuha", + "cmd.surface_blend": "Přechodová plocha", + "cmd.surface_boundary": "Plocha z hranic", + "cmd.surface_extrude": "Vysunutí plochy", + "cmd.surface_fair": "Vyhladit plochu", + "cmd.surface_fillet": "Zaoblení ploch", + "cmd.surface_loft": "Plocha spojením profilů", + "cmd.surface_match": "Napojit plochu", + "cmd.surface_offset": "Odsadit povrch", + "cmd.surface_patch": "Záplata", + "cmd.surface_reverse_normal": "Obrátit normálu", + "cmd.surface_revolve": "Rotace plochy", + "cmd.surface_ruled": "Přímková plocha", + "cmd.surface_stitch": "Sešít", + "cmd.surface_sweep": "Tažení plochy", + "cmd.surface_thicken": "Zesílit", + "cmd.surface_unstitch": "Rozešít", + "cmd.surface_untrim": "Zrušit oříznutí", + "cmd.timeline_add": "Přidat zaznamenaný prvek", + "cmd.timeline_compute_all": "Přepočítat vše", + "cmd.timeline_copy": "Kopírovat prvky", + "cmd.timeline_dependents": "Závislé prvky", + "cmd.timeline_export_script": "Exportovat skript příkazů", + "cmd.timeline_inspect": "Prozkoumat prvek", + "cmd.timeline_paste": "Vložit prvky", + "cmd.timeline_redefine": "Upravit prvek", + "cmd.timeline_rename": "Přejmenovat", + "cmd.timeline_reorder": "Změnit pořadí prvku", + "cmd.timeline_roll_to": "Přesunout značku historie", + "cmd.timeline_rollback": "Přesunout značku časové osy", + "cmd.timeline_suppress": "Potlačit prvek", + "cmd.view_apply": "Obnovit pojmenovaný pohled", + "cmd.view_delete": "Smazat pojmenovaný pohled", + "cmd.view_list": "Seznam pojmenovaných pohledů", + "cmd.view_navigate": "Navigovat pohled", + "cmd.view_rename": "Přejmenovat pojmenovaný pohled", + "cmd.view_save": "Nový pojmenovaný pohled", + "command name": "název příkazu", + "coupling.gear_1": "Kolo 1", + "coupling.gear_1_pick": "Kolo 1 (volitelné)", + "coupling.gear_2": "Kolo 2", + "coupling.gear_2_pick": "Kolo 2 (volitelné)", + "coupling.internal": "Vnitřní ozubení", + "coupling.needs_turn_and_slide": "vyžaduje jeden otočný a jeden posuvný kloub", + "coupling.needs_turns": "vyžaduje dva otočné klouby", + "coupling.not_round": "není kruhová plocha", + "coupling.nut": "Matice", + "coupling.picked": "vybráno", + "coupling.pinion": "Pastorek", + "coupling.pinion_pick": "Pastorek (volitelné)", + "coupling.pitch": "Stoupání", + "coupling.rack": "Hřeben", + "coupling.radius_1": "Poloměr 1", + "coupling.radius_2": "Poloměr 2", + "dialog.computing": "Výpočet… {seconds} s", + "dialog.custom_axis": "↖ Vlastní osa", + "dialog.edit_title": "UPRAVIT {title}", + "dialog.select": "↖ Vybrat", + "dialog.selected_count": "↖ Vybráno: {count}", + "dialog.selection_guidance": "Vstup změníte jeho výběrem. Kliknutím na vybraný objekt jej odeberete; Ctrl/Cmd+klik výběr také přepíná. Zkontrolujte náhled a stiskněte OK.", + "dialog.selection_step": "{step} z {total}: Vyberte {input}", + "dialog.step": "Krok", + "document.save_changes": "Uložit změny v {name}?", + "document.unsaved_changes": "{name} obsahuje neuložené změny. Pokud je neuložíte, budou ztraceny.", + "drive.stopped_by_contact": "Zastaveno na {value}: {a} by prošlo skrz {b}", + "drive.stopped_by_loop": "Zastaveno na {value}: smyčku přes {joint} už nelze dále uzavřít", + "drive.unconnected": "Nic se nepohne: {part} není spojeno s ničím ukotveným", + "dxf.cancel_wait": "Čekání na zastavení exportu…", + "dxf.cancelled": "Export DXF zrušen", + "dxf.cancelling": "Rušení exportu DXF", + "dxf.exporting": "Export do DXF", + "dxf.invalid_tolerance": "Tolerance křivek musí být mezi 0,0001 a 1 mm.", + "dxf.progress.index": "Příprava ploch (2/5)", + "dxf.progress.meshes": "Příprava sítí (1/5)", + "dxf.progress.occlusion": "Řešení skrytých čar (4/5)", + "dxf.progress.projection": "Promítání geometrie (3/5)", + "dxf.progress.write": "Zápis DXF (5/5)", + "dxf.tolerance": "Tolerance křivek", + "dxf.tolerance_help": "Pouze viditelná tělesa. Křivky jsou nahrazeny úsečkami. Menší tolerance dává jemnější křivky, ale zabere více času a paměti.", + "expression, e.g. 25 mm": "výraz, např. 25 mm", + "fits the face, e.g. M8": "odpovídá ploše, např. M8", + "from the rule": "z pravidla", + "home.documentation": "Dokumentace", + "home.in_this_browser": "v tomto prohlížeči", + "home.missing": "chybí", + "home.open": "Otevřít…", + "home.opened_days_ago": "Otevřeno před {count} dny", + "home.opened_hours_ago": "Otevřeno před {count} h", + "home.opened_just_now": "Otevřeno právě teď", + "home.opened_minutes_ago": "Otevřeno před {count} min", + "home.opening": "otevírání…", + "home.recent": "Nedávné", + "home.report_issue": "Nahlásit problém", + "home.samples": "Ukázky", + "home.whats_new": "Co je nového", + "install.button_tip": "Nainstalovat SolidCraft jako aplikaci: otevře se ve vlastním okně a funguje i offline", + "install.dock_1": "Zvolte Soubor › Přidat do Docku.", + "install.dock_2": "Klikněte na Přidat.", + "install.dock_after": "SolidCraft se pak otevírá z Docku ve vlastním okně a funguje i offline.", + "install.done": "Hotovo", + "install.home_screen_1": "Klepněte na Sdílet (v nabídce …, pokud není na liště).", + "install.home_screen_2": "Zvolte Přidat na plochu.", + "install.home_screen_3": "Klepněte na Přidat.", + "install.home_screen_after": "SolidCraft se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.", + "install.menu": "Nainstalovat aplikaci…", + "install.steps_intro": "Safari instaluje webové aplikace z vlastních nabídek:", + "install.title": "Instalace SolidCraftu", + "joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.", + "joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.", + "joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.", + "nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.", + "nav.touchpad.label": "Touchpad", + "nav.touchpad.orbit": "Otáčení (podržte Alt/Option a pohybujte; bez tlačítka myši)", + "nav.touchpad.pan": "Posun (podržte Shift a pohybujte; bez tlačítka myši)", + "nav.touchpad.scroll": "Touchpad přibližuje svislým posouváním. Vaše nastavení posunu dvěma prsty zůstává pro ostatní předvolby.", + "nav.touchpad.zoom": "Přiblížení (svislé posouvání nebo sevření prstů)", + "needs two joints": "vyžaduje dva klouby", + "new configuration": "nová konfigurace", + "new name": "nový název", + "new parameter": "nový parametr", + "no interference": "bez kolize", + "no joint that moves yet": "zatím žádný pohyblivý kloub", + "no picture": "bez obrázku", + "pattern.alignment_2": "Zarovnání 2", + "pattern.alignment_hint": "Počty zahrnují originál. Na střed rozloží kopie rovnoměrně; Převrátit volí stranu navíc u sudých počtů.", + "pattern.direction_1": "Směr 1", + "pattern.direction_2": "Směr 2", + "pattern.flip_direction": "Převrátit směr", + "pick a hole to seat it, or OK places it at the origin": "vyberte otvor pro usazení, nebo OK umístí do počátku", + "pick one or two vertices, edges or faces": "vyberte jeden nebo dva vrcholy, hrany či plochy", + "render.background": "Pozadí scény", + "render.fit": "Přizpůsobit produktu", + "render.image_cancel": "Zrušit render", + "render.image_cancelled": "Render zrušen", + "render.image_cancelling": "Rušení", + "render.image_check": "Zkontrolovat požadavek na render", + "render.image_completed": "Obrázek připraven k exportu", + "render.image_encoding": "Kódování PNG", + "render.image_export": "Exportovat PNG", + "render.image_failed": "Render selhal", + "render.image_height": "Výška", + "render.image_help": "Náhledový obrázek z CPU · PNG · přibližná průhlednost", + "render.image_overwrite": "Nahradit existující soubor", + "render.image_preparing": "Příprava obrázku", + "render.image_queued": "Čeká na render", + "render.image_reduce": "Zkuste menší obrázek nebo jednodušší scénu.", + "render.image_running": "Renderování obrázku", + "render.image_stale": "Tento obrázek používá scénu zachycenou před pozdějšími změnami.", + "render.image_start": "Spustit render", + "render.image_supported": "Požadavek na render je podporován; fronta a paměť se znovu ověří při spuštění.", + "render.image_title": "Renderovat obrázek", + "render.image_too_large": "Překročen limit obrázku: 4096 pixelů na stranu a celkem 4 194 304 pixelů.", + "render.image_transparent": "Průhledné pozadí", + "render.image_unknown": "Stav renderu není k dispozici", + "render.image_use_largest": "Použít největší velikost v tomto poměru stran:", + "render.image_width": "Šířka", + "render.navigation_help": "Kamera se ovládá stejně jako v návrhu (Předvolby › Navigace). Uložit nastavení zaznamená aktuální kameru.", + "render.opaque_image_summary": "Náhledové PNG · neprůhledné PBR · smíšená průhlednost", + "render.opaque_preview_help": "Neprůhledné PBR a jednoduché barvy; smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.", + "render.perspective": "Perspektivní kamera", + "render.preview": "Integrovaný náhled", + "render.preview_help": "Rastrové stínování a smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.", + "render.reload_settings": "Znovu načíst uložené nastavení", + "render.restore_camera": "Obnovit uloženou kameru", + "render.return_design": "Zpět k návrhu", + "render.save_setup": "Uložit nastavení", + "render.setup_name": "Název nastavení", + "render.unavailable_preview": "Toto nastavení vyžaduje nedostupný profil náhledu.", + "render.unsaved": "Neuložené změny náhledu", + "render.use_preview": "Použít integrovaný náhled", + "sample.bracket.about": "Základní příruba s okrajovými přírubami a lemem podle pravidla 2 mm", + "sample.bracket.name": "Plechový držák", + "sample.enclosure.about": "Skořepinová krabička s osazením, nálitky pro šrouby, opěrkou a zacvakávacím spojem", + "sample.enclosure.name": "Plastová krabička", + "sample.flange.about": "Náboj a příruba s kruhovým polem řízeným parametrem", + "sample.flange.name": "Šroubovaná příruba", + "sample.hinge.about": "Dvě komponenty a otočný kloub natočený na 60°", + "sample.hinge.name": "Sestava pantu", + "sample.plate.about": "Parametrická deska se zaobleními, otvory a vyvrtaným nálitkem", + "sample.plate.name": "Ukázková deska", + "sample.vents.about": "Drážky v obdélníkovém poli: řádky, sloupce a rozteč jsou parametry", + "sample.vents.name": "Větrací deska", + "settings.display_quality_note": "Jemnější úrovně kreslí zakřivené plochy a hrany hladčeji, ale síťování trvá déle. Mění se jen zobrazení, nikdy návrh ani exporty.", + "settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.", + "settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z", + "settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.", + "settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu", + "settings.msaa_reset": "{setting}: {requested}× toto grafické zařízení nepodporuje; nyní je nastaveno {reset}× (podporováno: {supported}).", + "settings.msaa_unsupported": "Toto grafické zařízení nepodporuje", + "settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění SolidCraftu, proto byla změněna:", + "settings.reset_title": "Nastavení byla změněna", + "settings.supersampling_note": "Vykresluje pohled ve vyšším rozlišení a poté jej zmenšuje: hladší hrany, čáry a mřížka na obrazovkách pod 4K. Stojí výkon GPU: asi 2,25× více pixelů při 1,5× a 4× při 2×.", + "sketch.degrees_of_freedom": "Stupně volnosti: {count}", + "sketch.draw_curves": "Kreslit jako {linetype}", + "sketch.make_selection": "Převést výběr na {linetype}", + "sketch_text.align_center": "Na střed", + "sketch_text.align_left": "Vlevo", + "sketch_text.align_right": "Vpravo", + "sketch_text.bold": "Tučné", + "sketch_text.builtin_font": "{family} (vestavěné)", + "sketch_text.font_search": "Hledat písma", + "sketch_text.italic": "Kurzíva", + "sketch_text.loading_fonts": "Hledání nainstalovaných písem…", + "sketch_text.on_path": "Text sleduje svou cestu: umístění určuje křivka", + "sketch_text.path_clear": "Odpojit text od křivky", + "sketch_text.path_hint": "Křivka náčrtu, kterou text sleduje: klikněte sem a poté na křivku", + "sketch_text.use_font_file": "Použít soubor písma {path}", + "the parts library is not available": "knihovna dílů není k dispozici", + "timeline.group": "Nová složka prvků", + "timeline.move_many": "Přesunout položky časové osy", + "timeline.move_to_group": "Přesunout prvky do složky", + "timeline.rename_group": "Přejmenovat složku prvků", + "timeline.reorder_many": "Změnit pořadí prvků", + "timeline.ungroup": "Odebrat složky prvků", + "to the next face": "k další ploše", + "toolbar.image": "OBRÁZEK", + "toolbar.render": "RENDER", + "toolbar.render_scene": "SCÉNA", + "trace.points": "Bodů podél cesty: {count}", + "trace.stopped_by_contact": "Končí ve vzorku {sample}: {a} by prošlo skrz {b}", + "trace.stopped_by_loop": "Končí ve vzorku {sample}: smyčku přes {joint} už nelze dále uzavřít", + "ui.1_degree_of_freedom": "1 stupeň volnosti", + "ui.3d_mouse_speed": "Rychlost 3D myši", + "ui.3d_mouse_windows": "3D myš", + "ui.3d_sketch": "3D náčrt", + "ui.about": "O aplikaci", + "ui.activate": "Aktivovat", + "ui.active_component": "Aktivní komponenta", + "ui.agent_chat": "Chat s agentem", + "ui.agent_chat_allow": "Povolit", + "ui.agent_chat_allowed": "Povoleno", + "ui.agent_chat_answered": "Odpověď: {option}", + "ui.agent_chat_approval": "Povolit agentovi tuto akci?", + "ui.agent_chat_attach_view": "Přiložit pohled", + "ui.agent_chat_attach_view_tip": "Odeslat se zprávou obrázek aktuálního pohledu", + "ui.agent_chat_auth_agent": "Přihlásit se přes {name}", + "ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte SolidCraft.", + "ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; SolidCraft vaše heslo ani klíče nikdy nevidí.", + "ui.agent_chat_auth_none": "Agent zde nenabízí žádný způsob přihlášení: přihlaste se podle jeho vlastní dokumentace.", + "ui.agent_chat_auth_retry": "Přihlásil(a) jsem se: připojit znovu", + "ui.agent_chat_auth_terminal": "Spusťte toto v terminálu a pak znovu stiskněte Připojit:", + "ui.agent_chat_auth_title": "Přihlásit se k agentovi", + "ui.agent_chat_cancel": "Zrušit", + "ui.agent_chat_changes": "Změny: {n}, jeden krok zpět", + "ui.agent_chat_changes_of": "Změny: {n}/{limit}", + "ui.agent_chat_close": "Zavřít", + "ui.agent_chat_command": "Příkaz agenta", + "ui.agent_chat_connect": "Připojit", + "ui.agent_chat_copy": "Kopírovat", + "ui.agent_chat_custom_hint": "Jakýkoli program, který komunikuje protokolem Agent Client Protocol přes stdin a stdout. Nespouští se přes shell.", + "ui.agent_chat_denied": "Zamítnuto", + "ui.agent_chat_deny": "Zamítnout", + "ui.agent_chat_desktop_only": "Chat s agentem vyžaduje desktopovou aplikaci.", + "ui.agent_chat_detecting": "Hledání agentů…", + "ui.agent_chat_dock_left": "Ukotvit vlevo", + "ui.agent_chat_dock_right": "Ukotvit vpravo", + "ui.agent_chat_edit": "Upravit", + "ui.agent_chat_exited": "Agent se zastavil.", + "ui.agent_chat_failed_start": "Agenta se nepodařilo spustit: {error}", + "ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.", + "ui.agent_chat_key_in_env": "klíč nastaven", + "ui.agent_chat_left": "Vlevo", + "ui.agent_chat_limit": "Agent dosáhl limitu {limit} změn na jednu zprávu. Pošlete další zprávu, aby mohl pokračovat.", + "ui.agent_chat_minimize": "Minimalizovat do pruhu", + "ui.agent_chat_model_tip": "Model, který agent používá. Vaše volba se pro tohoto agenta zapamatuje.", + "ui.agent_chat_new": "Nový chat", + "ui.agent_chat_new_confirm": "Začít nový chat? Aktuální bude vymazán.", + "ui.agent_chat_new_yes": "Nový chat", + "ui.agent_chat_no_cli": "Vedle aplikace nebyl nalezen solidcraft-cli, takže agent nemá přístup k návrhu.", + "ui.agent_chat_no_control": "Endpoint chatu s agentem se nespustil, takže agent nemá přístup k návrhu.", + "ui.agent_chat_no_images": "Tento agent nepřijímá obrázky", + "ui.agent_chat_none_found": "Nebyl nalezen žádný kódovací agent. Nainstalujte některého z těchto a stiskněte Obnovit:", + "ui.agent_chat_none_found_short": "Agent nenalezen", + "ui.agent_chat_not_installed": "Není nainstalován", + "ui.agent_chat_not_signed_in": "nepřihlášen", + "ui.agent_chat_permission": "Agent žádá o spuštění: {title}", + "ui.agent_chat_pick": "Agent", + "ui.agent_chat_placeholder": "Požádejte agenta o změnu návrhu…", + "ui.agent_chat_placeholder_busy": "Zařadit zprávu, až agent skončí (Ctrl+Enter: odeslat hned)", + "ui.agent_chat_privacy": "Nic se nikam neodesílá, jen agentovi, kterého spouštíte pod vlastním účtem.", + "ui.agent_chat_privacy_note": "SolidCraft nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. SolidCraft nikdy nevidí ani neukládá klíč či heslo.", + "ui.agent_chat_queue": "Zařadit", + "ui.agent_chat_queue_full": "Čeká příliš mnoho zpráv: některou odeberte nebo počkejte na agenta.", + "ui.agent_chat_queued": "Ve frontě", + "ui.agent_chat_queued_stopped": "Ve frontě (zastaveno, neodesláno)", + "ui.agent_chat_refresh": "Obnovit", + "ui.agent_chat_refused": "Pro agenta nepovoleno: {reason}", + "ui.agent_chat_remove": "Odebrat z fronty", + "ui.agent_chat_right": "Vpravo", + "ui.agent_chat_save": "Uložit", + "ui.agent_chat_send": "Odeslat", + "ui.agent_chat_send_now": "Odeslat hned", + "ui.agent_chat_send_now_tip": "Zastavit aktuální krok agenta a poslat tuto zprávu jako další (Ctrl+Enter)", + "ui.agent_chat_side": "Strana chatu s agentem", + "ui.agent_chat_signed_in": "přihlášen", + "ui.agent_chat_state_auth": "Vyžadováno přihlášení", + "ui.agent_chat_state_busy": "Pracuje…", + "ui.agent_chat_state_exited": "Agent zastaven", + "ui.agent_chat_state_off": "Nepřipojeno", + "ui.agent_chat_state_ready": "Připraveno", + "ui.agent_chat_state_starting": "Spouštění…", + "ui.agent_chat_stop": "Zastavit", + "ui.agent_chat_stop_tip": "Zastavit agenta a vše, co spustil", + "ui.agent_chat_stop_turn_tip": "Zastavit agenta (Esc). Zprávy ve frontě zůstanou.", + "ui.agent_chat_turn_limit": "Změn na zprávu", + "ui.agent_chat_undo_changes": "Vrátit tyto změny", + "ui.agent_chat_undo_label": "Chat s agentem: {message}", + "ui.agent_chat_undone": "(vráceno)", + "ui.agent_chat_view_attached": "Pohled přiložen", + "ui.agent_cursor": "Kurzor agenta", + "ui.agent_cursor_speed": "Rychlost kurzoru agenta", + "ui.ai_add": "Přidat", + "ui.ai_add_agent": "Přidat agenta…", + "ui.ai_add_agent_title": "Přidat agenta", + "ui.ai_add_api": "API kompatibilní s OpenAI", + "ui.ai_add_custom": "Vlastní příkaz", + "ui.ai_add_installed": "Nainstalovaný agent", + "ui.ai_add_installed_note": "Vlastní položka pro nainstalovaného agenta, například s jinými argumenty. Nainstalovaní agenti se v seznamu zobrazují sami.", + "ui.ai_added": "přidáno", + "ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje SolidCraftu se řídí oprávněními v Předvolby > AI.", + "ui.ai_agents": "Agenti", + "ui.ai_always_allow": "Vždy povolit", + "ui.ai_always_allow_tip": "Od teď povolit {tool} tomuto agentovi bez ptaní", + "ui.ai_always_allowed": "Vždy povoleno", + "ui.ai_always_allowed_none": "Zatím není nic trvale povoleno. Zde se zobrazí odpovědi „Vždy povolit“ zadané v chatu.", + "ui.ai_auto_allowed": "Automaticky povoleno: {title} ({why})", + "ui.ai_auto_mode": "vaše nastavení oprávnění", + "ui.ai_auto_ours": "SolidCraft své nástroje kontroluje sám", + "ui.ai_auto_remembered": "zvolili jste Vždy povolit", + "ui.ai_bad_url": "Zadejte URL začínající http:// nebo https://", + "ui.ai_base_url": "Základní URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "kopie", + "ui.ai_custom_url": "Vlastní URL", + "ui.ai_default": "Výchozí", + "ui.ai_http_busy": "endpoint je zaneprázdněný nebo překročil limit; zkuste to později", + "ui.ai_http_key": "endpoint odmítl klíč: zkontrolujte jej v Předvolby > AI", + "ui.ai_http_not_found": "takový endpoint nebo model neexistuje: zkontrolujte základní URL a název modelu", + "ui.ai_key": "API klíč", + "ui.ai_key_hint": "volitelné; uloženo v systémové klíčence", + "ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.", + "ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení SolidCraftu. Pro místní server nechte prázdné.", + "ui.ai_keychain_error": "Systémová klíčenka odmítla: {error}", + "ui.ai_mode_all": "Povolit vše", + "ui.ai_mode_all_note": "Nic se nejdřív neptá. Hlášení o pádech zůstávají nepřístupná a limity složek stále platí.", + "ui.ai_mode_all_warning": "Agent může bez ptaní ukládat, přepisovat, exportovat a nahradit váš návrh. Používejte to jen s agentem, kterému důvěřujete, a na uložených návrzích.", + "ui.ai_mode_ask": "Ptát se pokaždé", + "ui.ai_mode_ask_note": "Každá změna návrhu a každá operace se soubory čeká na vaše Povolit.", + "ui.ai_mode_edits": "Automaticky povolit úpravy modelu", + "ui.ai_mode_edits_note": "Modelovací příkazy běží bez ptaní; ukládání, otevírání, export a další souborové či destruktivní akce se stále ptají.", + "ui.ai_mode_global": "Podle nastavení výše", + "ui.ai_model": "Model", + "ui.ai_modes_scope": "Tato nastavení se týkají nástrojů SolidCraftu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.", + "ui.ai_name": "Název", + "ui.ai_need_command": "Zadejte příkaz, který spouští agenta", + "ui.ai_need_model": "Zadejte název modelu", + "ui.ai_no_answer": "Agent neodpověděl včas", + "ui.ai_no_http": "Tato verze se nemůže připojit k API endpointům", + "ui.ai_no_keychain": "Systémová klíčenka není k dispozici, klíč tedy nelze uložit. Místní server bez klíče funguje dál.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Oprávnění", + "ui.ai_preset": "Předvolba", + "ui.ai_remove": "Odebrat", + "ui.ai_request_limit": "zastaveno po 24 požadavcích na jednu zprávu; pro pokračování pošlete další zprávu", + "ui.ai_reset": "Obnovit", + "ui.ai_settings": "Nastavení AI…", + "ui.ai_show_features": "Zobrazit funkce AI", + "ui.ai_show_features_note": "Chat s agentem: panel, ve kterém na otevřeném návrhu pracuje kódovací agent, kterého již používáte, nebo model na zvoleném endpointu. Vypnuto: žádné tlačítko AI, zkratka, položka nabídky ani panel a nic se nehledá ani nespouští.", + "ui.ai_switch_tip": "Změnit agenta pro tuto konverzaci", + "ui.ai_test": "Test", + "ui.ai_test_acp_ok": "Odpovídá: {agent}", + "ui.ai_test_acp_tip": "Krátce spustí agenta, požádá jej o relaci a zase jej ukončí", + "ui.ai_test_api_tip": "Požádá endpoint o seznam modelů", + "ui.ai_test_no_model": "Spojeno: modelů {count}, ale chybí {model}", + "ui.ai_test_ok": "Spojeno: modelů {count}", + "ui.ai_testing": "Testování…", + "ui.ai_tool_done": "hotovo", + "ui.ai_tool_failed": "selhalo", + "ui.ai_tool_running": "běží", + "ui.ai_tool_unfold": "Kliknutím zobrazíte, co vrátil", + "ui.ai_tool_waiting": "čeká", + "ui.ai_toolbar_button": "Zobrazit tlačítko na panelu nástrojů", + "ui.ai_toolbar_button_note": "Tlačítko AI na pravém konci panelu nástrojů. Ctrl+Shift+A a nabídka Zobrazit panel otevřou i tak.", + "ui.ai_undo_tip": "Vrátit jedním krokem vše, co agent u této zprávy změnil", + "ui.all": "Vše", + "ui.analysis": "Analýza", + "ui.angle": "Úhel", + "ui.angle_decimals": "Desetinná místa úhlu", + "ui.animateJoint": "Animovat kloub", + "ui.animate_view_changes": "Animovat změny pohledu", + "ui.animation": "Animace", + "ui.animation_speed": "Rychlost animace", + "ui.anti_aliasing": "Vyhlazování hran", + "ui.apply": "Použít", + "ui.arc": "Oblouk", + "ui.assemble": "Sestavit", + "ui.autosave_every": "Automaticky ukládat každých", + "ui.axes": "Osy", + "ui.axis": "Osa", + "ui.base_point": "Základní bod", + "ui.blocked": "Blokováno", + "ui.bodies": "Tělesa", + "ui.box": "Kvádr", + "ui.break": "Přerušit", + "ui.calibrate": "Kalibrovat", + "ui.cancel": "Zrušit", + "ui.canvases": "Plátna", + "ui.center": "Střed", + "ui.centerline": "Osa", + "ui.chain": "Řetězec", + "ui.chamfer": "Sražení", + "ui.chamfer_type": "Typ sražení", + "ui.change_workspace": "Změnit pracovní prostředí", + "ui.chord_length": "Délka tětivy", + "ui.circle": "Kružnice", + "ui.circular_pattern": "Kruhové pole", + "ui.clear": "Vymazat", + "ui.clear_recent": "Vymazat nedávné", + "ui.clear_the_selection": "Zrušit výběr", + "ui.click_selects_whole_bodies": "Kliknutí vybírá celá tělesa", + "ui.click_then_press_key": "Klikněte a stiskněte novou klávesu", + "ui.close": "Zavřít", + "ui.coil": "Šroubovice", + "ui.coincident": "Totožnost", + "ui.collapse": "Sbalit", + "ui.collinear": "Kolinearita", + "ui.colour_vision": "Barevné vidění", + "ui.colour_vision_deutan": "Deuteranopie", + "ui.colour_vision_deutan_tip": "Pro oslabené nebo chybějící vnímání zelené", + "ui.colour_vision_failed": "Selhalo", + "ui.colour_vision_fixed": "Pevné", + "ui.colour_vision_mode": "Režim", + "ui.colour_vision_note": "Mění barvy, které nesou význam: stavy náčrtu, osy, ohyby, analýzy a ikony. Pokud používáte barevné filtry Windows, nechte vypnuto.", + "ui.colour_vision_off": "Vypnuto", + "ui.colour_vision_off_tip": "Vlastní barvy motivu", + "ui.colour_vision_projected": "Promítnuté", + "ui.colour_vision_protan": "Protanopie", + "ui.colour_vision_protan_tip": "Pro oslabené nebo chybějící vnímání červené", + "ui.colour_vision_sample": "Ukázka", + "ui.colour_vision_tritan": "Tritanopie", + "ui.colour_vision_tritan_tip": "Pro oslabené nebo chybějící vnímání modré", + "ui.combine": "Kombinovat", + "ui.coming_later": "připravujeme", + "ui.command_done": "{command} hotovo", + "ui.command_palette_hint": "Zadejte příkaz, např. {example} — nebo stiskněte S pro hledání", + "ui.concentric": "Soustřednost", + "ui.configurations": "Konfigurace", + "ui.conic_curve": "Kuželosečka", + "ui.constant": "Konstantní", + "ui.constraints_conflict": "Konflikt vazeb", + "ui.construct": "Konstrukce", + "ui.construction": "Konstrukční", + "ui.context_menu": "Místní nabídka", + "ui.copy": "Kopírovat", + "ui.corner": "Roh", + "ui.corners": "Rohy", + "ui.counterbore": "Válcové zahloubení", + "ui.countersink": "Kuželové zahloubení", + "ui.create": "Vytvořit", + "ui.create_copy": "Vytvořit kopii", + "ui.create_sketch": "Vytvořit náčrt", + "ui.current": "aktuální", + "ui.curvature": "Křivost", + "ui.curvature_g2": "Křivost (G2)", + "ui.curves": "Křivky", + "ui.custom_program": "Vlastní…", + "ui.custom_program_tip": "Vyberte slicer, který se přidá do seznamu", + "ui.customizeToolbar": "Přizpůsobit panel nástrojů…", + "ui.cut": "Vyjmout", + "ui.cylinder": "Válec", + "ui.dark": "Tmavý", + "ui.default_modelling_orientation": "Výchozí orientace modelování", + "ui.degrees_of_freedom": "stupňů volnosti", + "ui.delete": "Smazat", + "ui.depth": "Hloubka", + "ui.design": "Návrh", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Popis", + "ui.design_properties.exports": "Exporty STEP, IGES a 3MF tyto údaje přenášejí, pokud pro ně formát má místo.", + "ui.design_properties.license": "Licence", + "ui.design_properties.not_text": "Uloženo jinak než jako text; psaním zde hodnotu nahradíte.", + "ui.design_properties.organization": "Organizace", + "ui.design_properties.others": "Data jiných nástrojů", + "ui.design_properties.others_note": "Uchovávají se v návrhu beze změny a nikdy se neexportují.", + "ui.design_properties.part_number": "Číslo dílu", + "ui.design_properties.project": "Projekt", + "ui.design_properties.revision": "Revize", + "ui.diameter": "Průměr", + "ui.dimension": "Kóta", + "ui.direction": "Směr", + "ui.display_quality": "Kvalita zobrazení", + "ui.display_quality_coarse": "Hrubá", + "ui.display_quality_fine": "Jemná", + "ui.display_quality_normal": "Normální", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Vzdálenost", + "ui.distance_1": "Vzdálenost 1", + "ui.distance_2": "Vzdálenost 2", + "ui.document_settings": "Nastavení dokumentu", + "ui.don_t_save": "Neukládat", + "ui.draft": "Úkos", + "ui.drawing": "Výkres", + "ui.drill_point": "Špička vrtáku", + "ui.edit": "Upravit", + "ui.editJointLimits": "Upravit limity kloubu", + "ui.edit_section_analysis": "Upravit analýzu řezu", + "ui.ellipse": "Elipsa", + "ui.end_radius": "Koncový poloměr", + "ui.end_scale": "Koncové měřítko", + "ui.entities": "Prvky", + "ui.equal": "Rovnost", + "ui.equivalent_radius": "Ekvivalentní poloměr", + "ui.expand": "Rozbalit", + "ui.exportDxf": "Exportovat DXF…", + "ui.export_ellipsis": "Exportovat...", + "ui.export_ellipsis_keymap": "Exportovat…", + "ui.export_keymap_tip": "Klávesy, oblíbené z vyhledávání S a panel nástrojů jako soubor", + "ui.extend": "Prodloužit", + "ui.extent_type": "Typ rozsahu", + "ui.extrude": "Vysunutí", + "ui.factor": "Činitel", + "ui.feature_history": "Historie prvků", + "ui.feature_options": "Možnosti prvku", + "ui.features": "Prvky", + "ui.file": "Soubor", + "ui.fillet": "Zaoblení", + "ui.filter_commands_or_keys": "Filtrovat příkazy nebo klávesy", + "ui.findInTimeline": "Najít na časové ose", + "ui.findInWindow": "Najít v okně", + "ui.finish_sketch": "Dokončit náčrt", + "ui.fit": "Přizpůsobit", + "ui.fix_unfix": "Zafixovat/uvolnit", + "ui.flat": "Plochý", + "ui.flip": "Převrátit", + "ui.format": "Formát", + "ui.frame_rate": "Snímková frekvence", + "ui.front": "Zepředu", + "ui.fully_constrained": "Plně zavazbeno", + "ui.general": "Obecné", + "ui.graphics": "Grafika", + "ui.ground_shadow": "Stín na podložce", + "ui.groupSelected": "Seskupit vybrané", + "ui.heading_add_ins": "DOPLŇKY", + "ui.heading_assemble": "SESTAVA", + "ui.heading_box": "KVÁDR", + "ui.heading_chamfer": "SRAŽENÍ", + "ui.heading_circular_pattern": "KRUHOVÉ POLE", + "ui.heading_coil": "ŠROUBOVICE", + "ui.heading_combine": "KOMBINOVAT", + "ui.heading_configure": "KONFIGURACE", + "ui.heading_constraints": "VAZBY", + "ui.heading_construct": "KONSTRUKCE", + "ui.heading_create": "VYTVOŘIT", + "ui.heading_create_sketch": "VYTVOŘIT NÁČRT", + "ui.heading_cylinder": "VÁLEC", + "ui.heading_design": "NÁVRH", + "ui.heading_draft": "ÚKOS", + "ui.heading_edit": "UPRAVIT", + "ui.heading_extrude": "VYSUNUTÍ", + "ui.heading_fillet": "ZAOBLENÍ", + "ui.heading_finish_sketch": "DOKONČIT NÁČRT", + "ui.heading_front": "ZEPŘEDU", + "ui.heading_hole": "OTVOR", + "ui.heading_home": "DOMŮ", + "ui.heading_insert": "VLOŽIT", + "ui.heading_inspect": "ANALÝZA", + "ui.heading_loft": "SPOJENÍ PROFILŮ", + "ui.heading_make": "VÝROBA", + "ui.heading_measure": "MĚŘENÍ", + "ui.heading_mesh": "SÍŤ", + "ui.heading_mirror": "ZRCADLIT", + "ui.heading_modify": "UPRAVIT", + "ui.heading_move": "POSUNOUT", + "ui.heading_move_copy": "POSUNOUT/KOPÍROVAT", + "ui.heading_offset": "ODSAZENÍ", + "ui.heading_offset_plane": "ODSAZENÁ ROVINA", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARAMETRY", + "ui.heading_pattern": "POLE", + "ui.heading_pattern_on_path": "POLE PO CESTĚ", + "ui.heading_pipe": "TRUBKA", + "ui.heading_plane_at_angle": "ROVINA POD ÚHLEM", + "ui.heading_plastic": "PLASTY", + "ui.heading_position": "POLOHA", + "ui.heading_prepare": "PŘÍPRAVA", + "ui.heading_rectangular_pattern": "OBDÉLNÍKOVÉ POLE", + "ui.heading_revolve": "ROTACE", + "ui.heading_right": "ZPRAVA", + "ui.heading_save_mesh": "ULOŽIT JAKO SÍŤ", + "ui.heading_scale": "MĚŘÍTKO", + "ui.heading_section_analysis": "ANALÝZA ŘEZU", + "ui.heading_select": "VÝBĚR", + "ui.heading_setup": "NASTAVENÍ", + "ui.heading_sheet_metal": "PLECH", + "ui.heading_shell": "SKOŘEPINA", + "ui.heading_sketch": "NÁČRT", + "ui.heading_sketch_palette": "PALETA NÁČRTU", + "ui.heading_sketch_scale": "MĚŘÍTKO NÁČRTU", + "ui.heading_solid": "TĚLESO", + "ui.heading_sphere": "KOULE", + "ui.heading_split_body": "ROZDĚLIT TĚLESO", + "ui.heading_surface": "PLOCHY", + "ui.heading_sweep": "TAŽENÍ", + "ui.heading_thread": "ZÁVIT", + "ui.heading_top": "SHORA", + "ui.heading_torus": "ANULOID", + "ui.heading_utilities": "NÁSTROJE", + "ui.heading_utility": "NÁSTROJE", + "ui.help": "Nápověda", + "ui.hide": "Skrýt", + "ui.hideAxes": "Skrýt osy", + "ui.hideDimension": "Skrýt kótu", + "ui.hideOrigin": "Skrýt počátek", + "ui.hidePlanes": "Skrýt roviny", + "ui.hideProfile": "Skrýt profil", + "ui.hole": "Otvor", + "ui.hole_tap_type": "Typ závitu otvoru", + "ui.hole_type": "Typ otvoru", + "ui.home": "Domů", + "ui.homeView": "Výchozí pohled", + "ui.horizontal_vertical": "Vodorovně/svisle", + "ui.import_ellipsis": "Importovat…", + "ui.infinite": "Nekonečná", + "ui.insert": "Vložit", + "ui.inspect": "Analýza", + "ui.intersect": "Průnik", + "ui.isolate": "Izolovat", + "ui.join": "Sjednotit", + "ui.joints": "Klouby", + "ui.keyboard_shortcuts": "Klávesové zkratky", + "ui.language": "Jazyk", + "ui.length_decimals": "Desetinná místa délky", + "ui.light": "Světlý", + "ui.line": "Úsečka", + "ui.linetype": "Typ čáry", + "ui.lock": "Zamknout", + "ui.loft": "Spojení profilů", + "ui.look_at": "Pohled kolmo", + "ui.look_at_anim_desc": "Pohled podél osy, pohled kolmo, výchozí pohled a přizpůsobení", + "ui.manufacture": "Výroba", + "ui.material_review.axes": "Lokální osy materiálu; orientace tělesa není přiřazena.", + "ui.material_review.claims": "Zdroje a licence jsou neověřená tvrzení. Chybějící hodnoty zůstávají neznámé.", + "ui.material_review.compressive_strength": "Pevnost v tlaku", + "ui.material_review.conditions": "Uvedené podmínky", + "ui.material_review.density": "Hustota", + "ui.material_review.derived": "Odvozené izotropní moduly", + "ui.material_review.elasticity": "Pružnost", + "ui.material_review.input": "Vložte materiálovou kartu podle schématu 1 (JSON)", + "ui.material_review.limit": "Karta překračuje vstupní limit 256 KiB.", + "ui.material_review.moisture_unit": "kg vody / kg suchého materiálu", + "ui.material_review.properties": "Vlastnosti", + "ui.material_review.review": "Zkontrolovat kartu", + "ui.material_review.scope": "Pouze kandidát: nepřiřazeno ani neuloženo. Numerické ověření není inženýrská certifikace.", + "ui.material_review.shear_strength": "Pevnost ve smyku", + "ui.material_review.source": "Zdroj", + "ui.material_review.specific_heat": "Měrná tepelná kapacita", + "ui.material_review.tensile_strength": "Pevnost v tahu", + "ui.material_review.thermal_conductivity": "Tepelná vodivost", + "ui.material_review.thermal_expansion": "Tepelná roztažnost", + "ui.material_review.title": "Kontrola dat fyzického materiálu", + "ui.material_review.unknown": "Neznámé", + "ui.material_review.yield_strength": "Mez kluzu", + "ui.measure": "Měřit", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF se značkami ploch", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Střed", + "ui.mirror": "Zrcadlit", + "ui.mirror_line": "Osa zrcadlení", + "ui.modify": "Upravit", + "ui.move": "Posunout", + "ui.multiple": "Více", + "ui.named_views": "Pojmenované pohledy", + "ui.navigation": "Navigace", + "ui.needs_design": "{command}: nejprve otevřete návrh", + "ui.newSketchGroup": "Nová skupina náčrtů", + "ui.new_body": "Nové těleso", + "ui.new_design": "Nový návrh", + "ui.no_slicer_found": "Nebyl nalezen žádný slicer", + "ui.normal": "Běžná", + "ui.normalConstruction": "Běžná / konstrukční", + "ui.not_a_program": "{path} není spustitelný program", + "ui.object_type": "Typ objektu", + "ui.objects": "Objekty", + "ui.offer_to_stand_up_y_up_files": "Nabízet postavení souborů s osou Y nahoru", + "ui.offset": "Odsazení", + "ui.offset_plane": "Odsazená rovina", + "ui.one_side": "Jedna strana", + "ui.open": "Otevřít", + "ui.open_ellipsis": "Otevřít...", + "ui.open_preferences": "Otevřít předvolby", + "ui.open_recent": "Otevřít nedávné", + "ui.operation": "Operace", + "ui.options": "Možnosti", + "ui.orbit": "Otáčení", + "ui.orbit_around": "Otáčet kolem", + "ui.orientation": "Orientace", + "ui.origin": "Počátek", + "ui.palette_points": "Body", + "ui.palette_profile": "Profil", + "ui.pan": "Posun", + "ui.pan_zoom_orbit": "Posun, přiblížení, otáčení", + "ui.parallel": "Rovnoběžnost", + "ui.parameters": "Parametry", + "ui.paste": "Vložit", + "ui.path": "Cesta", + "ui.pattern_on_path": "Pole po cestě", + "ui.perpendicular": "Kolmost", + "ui.perspective_view": "Perspektivní pohled", + "ui.pinToShortcuts": "Připnout ke zkratkám", + "ui.pinToToolbar": "Připnout na panel nástrojů", + "ui.pipe": "Trubka", + "ui.placement": "Umístění", + "ui.plane": "Rovina", + "ui.plane_at_angle": "Rovina pod úhlem", + "ui.point": "Bod", + "ui.point_under_the_cursor": "Bod pod kurzorem", + "ui.points": "Body", + "ui.polygon": "Mnohoúhelník", + "ui.preferences": "Předvolby", + "ui.press_a_key": "stiskněte klávesu…", + "ui.profile": "Profil", + "ui.profile_plane": "Rovina profilu", + "ui.profiles": "Profily", + "ui.project": "Promítnout", + "ui.project_face_edges_sketch": "Promítat hrany plochy do nových náčrtů", + "ui.properties": "Vlastnosti", + "ui.pull_direction": "Směr vyjímání", + "ui.quantity": "Počet", + "ui.quantity_2": "Počet 2", + "ui.quit": "Ukončit", + "ui.radial_context_menu": "Kruhová místní nabídka", + "ui.radius": "Poloměr", + "ui.radius_type": "Typ poloměru", + "ui.reachable": "Dosažitelné", + "ui.rectangle": "Obdélník", + "ui.rectangular_pattern": "Obdélníkové pole", + "ui.redo": "Znovu", + "ui.removeFromToolbar": "Odebrat z panelu nástrojů", + "ui.removeSection": "Odebrat řez", + "ui.render": "Render", + "ui.renderer": "Vykreslovač", + "ui.repeat": "Opakovat", + "ui.report_a_bug": "Nahlásit chybu", + "ui.reset": "Obnovit", + "ui.reset_all": "Obnovit vše", + "ui.reset_the_settings_on_this_page": "Obnovit nastavení na této stránce", + "ui.restore": "Obnovit", + "ui.restore_defaults": "Obnovit výchozí", + "ui.reverse_zoom_direction": "Obrátit směr přiblížení", + "ui.revolve": "Rotace", + "ui.right": "Zprava", + "ui.save": "Uložit", + "ui.save_as": "Uložit jako", + "ui.save_as_ellipsis": "Uložit jako...", + "ui.save_mesh_no_slicer": "Nebyl nalezen žádný slicer: zvolte Vlastní… a vyberte svůj slicer, nebo vypněte Odeslat do sliceru", + "ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení", + "ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}", + "ui.saved_to": "Uloženo {path}", + "ui.scale": "Měřítko", + "ui.section_analysis": "Analýza řezu", + "ui.select": "Vybrat", + "ui.selectOther": "Vybrat jiné…", + "ui.selectTangentChain": "Vybrat tečný řetězec", + "ui.select_other_hint": "Vybrat jiné: podržte levé tlačítko na modelu (nebo pravé tlačítko › Vybrat jiné…) a zobrazí se vše pod kurzorem; dalším kliknutím na stejné místo vyberete další prvek.", + "ui.selection": "Výběr", + "ui.send": "Odeslat", + "ui.send_to_slicer": "Odeslat do sliceru", + "ui.sent_to_slicer": "Odesláno do {slicer}", + "ui.shell": "Skořepina", + "ui.show": "Zobrazit", + "ui.showAll": "Zobrazit vše", + "ui.showAllBodies": "Zobrazit všechna tělesa", + "ui.showAxes": "Zobrazit osy", + "ui.showDimension": "Zobrazit kótu", + "ui.showOrigin": "Zobrazit počátek", + "ui.showPlanes": "Zobrazit roviny", + "ui.show_constraints": "Vazby", + "ui.show_construction_geometries": "Konstrukční geometrie", + "ui.show_dimensions": "Kóty", + "ui.show_points": "Zobrazit body", + "ui.show_profile": "Zobrazit profil", + "ui.show_projected_geometries": "Promítnuté geometrie", + "ui.simple": "Jednoduchý", + "ui.simulation": "Simulace", + "ui.single": "Jeden", + "ui.sketch": "Náčrt", + "ui.sketch_dimension": "Kóta náčrtu", + "ui.sketch_grid": "Mřížka náčrtu", + "ui.sketch_scale": "Měřítko náčrtu", + "ui.sketch_status": "Stav náčrtu", + "ui.sketches": "Náčrty", + "ui.slice": "Řez", + "ui.slicer": "Slicer", + "ui.slicer_delete_tip": "{program}\nKlávesou Delete jej odeberete ze seznamu", + "ui.slot": "Drážka", + "ui.smooth": "Hladký", + "ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem", + "ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání", + "ui.snap": "Přichytávání", + "ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.", + "ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse", + "ui.spacing": "Rozteč", + "ui.spacing_2": "Rozteč 2", + "ui.sphere": "Koule", + "ui.spline": "Splajn", + "ui.split_body": "Rozdělit těleso", + "ui.stand_up_tip": "Když se soubor STEP nebo IGES otevře s osou Z nahoru, nabídnout otočení o čtvrt otáčky kolem X", + "ui.start": "Start", + "ui.start_radius": "Počáteční poloměr", + "ui.supersampling": "Supersampling (SSAA)", + "ui.supersampling_adaptive": "Jen když je pohled v klidu", + "ui.supersampling_off": "Vypnuto (1×)", + "ui.suppress": "Potlačit", + "ui.sweep": "Tažení", + "ui.sweep_follow_direction": "Sledovat směr", + "ui.sweep_keep_as_drawn": "Zachovat jak nakresleno", + "ui.sweep_turn_with_path": "Natáčet podle cesty", + "ui.symmetric": "Symetricky", + "ui.symmetry": "Symetrie", + "ui.system": "Systém", + "ui.tangent": "Tečnost", + "ui.taper_angle": "Úhel zúžení", + "ui.tapped": "Se závitem", + "ui.text": "Text", + "ui.theme": "Motiv", + "ui.thickness": "Tloušťka", + "ui.thread": "Závit", + "ui.timeline_bottom": "Časová osa dole", + "ui.timeline_left_note": "Rozložení vlevo zobrazuje lištu historie nad prohlížečem dílů a scény.", + "ui.timeline_left_sidebar": "Levý postranní panel", + "ui.tip_angle": "Úhel špičky", + "ui.to_object": "K objektu", + "ui.tool_direction": "Směr nástroje", + "ui.top": "Shora", + "ui.torus": "Anuloid", + "ui.total_angle": "Celkový úhel", + "ui.trackpad_pan_tip": "Gesta na trackpadu posouvají (vypnuto: přibližují jako kolečko); kolečko myši přibližuje, palcové kolečko posouvá do stran, sevření prstů vždy přibližuje", + "ui.trim": "Oříznout", + "ui.twist": "Zkroucení", + "ui.two_finger_scroll_pans": "Posouvání dvěma prsty posouvá pohled", + "ui.two_sides": "Dvě strany", + "ui.ui_scale": "Měřítko rozhraní", + "ui.undo": "Zpět", + "ui.unground": "Zrušit ukotvení", + "ui.unisolate": "Zrušit izolaci", + "ui.units": "Jednotky", + "ui.units_of_new_designs": "Jednotky nových návrhů", + "ui.unlock": "Odemknout", + "ui.unsuppress": "Zrušit potlačení", + "ui.untitled": "Bez názvu", + "ui.up_axis_note": "Která osa v pohledech míří nahoru (pohled podél osy, výchozí pohled, pohled kolmo). Návrhy se nemění.", + "ui.updateToCurrentView": "Aktualizovat na aktuální pohled", + "ui.variable": "Proměnný", + "ui.view_centre": "Střed pohledu", + "ui.visual_shaded_hidden_edges": "Stínovaný se skrytými hranami", + "ui.visual_shaded_with_edges": "Stínovaný s hranami", + "ui.visual_style": "Vizuální styl", + "ui.visual_wireframe": "Drátový model", + "ui.workspace": "pracovní prostředí", + "ui.x_distance": "Vzdálenost X", + "ui.y_distance": "Vzdálenost Y", + "ui.y_up": "Y nahoru", + "ui.z_up": "Z nahoru", + "ui.zoom": "Přiblížení", + "ui.zoom_to_cursor_tip": "Kolečko přibližuje k bodu pod kurzorem (vypnuto: ke středu pohledu)", + "ui.zoom_to_the_cursor": "Přibližovat ke kurzoru", + "update.available": "Je k dispozici SolidCraft {version}", + "update.button": "Aktualizovat", + "update.button_tip": "Je k dispozici SolidCraft {version}", + "update.button_version": "Aktualizovat na {version}", + "update.check_automatically": "Automaticky kontrolovat aktualizace", + "update.check_now": "Zkontrolovat nyní", + "update.checking": "Kontrola…", + "update.copy": "Kopírovat", + "update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte SolidCraft do Aplikací místo staré kopie a poté SolidCraft restartujte.", + "update.download": "Stáhnout", + "update.download_open": "Stáhnout a otevřít", + "update.downloading": "Stahování a ověřování… {done} z {total} MB", + "update.failed": "Kontrola se nezdařila: {error}", + "update.file": "Stáhne se {file}.", + "update.install_failed": "Aktualizace se nenainstalovala: {error}. Nic se nezměnilo; tlačítkem Stáhnout získáte balíček.", + "update.install_msi": "Nainstalovat (zavře SolidCraft)", + "update.installed": "SolidCraft {version} je nainstalován. Pro použití restartujte.", + "update.installer_started": "Instalátor se spustí po zavření SolidCraftu.", + "update.later": "Připomenout později", + "update.no_notes": "Tato verze nemá poznámky.", + "update.package_hint": "Tato kopie pochází z {kind}. Stáhněte nový balíček a nainstalujte jej pomocí:", + "update.prefs_dev": "Toto je vývojová verze ({version}): aktualizace nekontroluje.", + "update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. SolidCraft nekontroluje.", + "update.prefs_label": "Aktualizace", + "update.prefs_note": "Toto je SolidCraft {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.", + "update.release_page": "Otevřít stránku vydání", + "update.restart": "Restartovat nyní", + "update.restart_later": "Později", + "update.show": "Zobrazit aktualizaci na {version}", + "update.skip": "Přeskočit tuto verzi", + "update.system_hint": "Tato kopie je {kind}. Aktualizujte ji pomocí:", + "update.title": "Je k dispozici aktualizace", + "update.up_to_date": "SolidCraft je aktuální.", + "update.update_now": "Aktualizovat nyní", + "update.you_have": "Máte {version}." +} diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 63eccee7..9069f2b7 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -711,6 +711,7 @@ fn the_sections_messages_are_in_every_catalog() { .chain(["ui.colour_vision", "ui.colour_vision_mode", "ui.colour_vision_sample", "ui.colour_vision_note"]) .collect(); for (lang, text) in [ + ("cs", include_str!("../../../assets/locales/cs.json")), ("de", include_str!("../../../assets/locales/de.json")), ("ja", include_str!("../../../assets/locales/ja.json")), ("ko", include_str!("../../../assets/locales/ko.json")), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d40e0bd7..f5178f36 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -32,6 +32,7 @@ impl Catalog { } static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json"))); +static CZECH: Catalog = Catalog::new("cs", "Čeština", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/cs.json"))); static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json"))); static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json"))); static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json"))); @@ -49,6 +50,7 @@ static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env! #[rustfmt::skip] pub const LOCALES: &[&Catalog] = &[ &ENGLISH, + &CZECH, &GERMAN, &SPANISH, &FRENCH, @@ -237,12 +239,12 @@ mod tests { } /// Catalogs delivered complete. English is the source catalog; Japanese was completed - /// upstream and Chinese lands complete here. German, Korean, Russian, French and Ukrainian + /// upstream, Chinese and Czech land complete here. German, Korean, Russian, French and Ukrainian /// are partial (German and Korean predate the coverage guards; Russian, French and Ukrainian /// are kept partial so a new label doesn't need more translations), so their gaps fall back /// to English and are reported by `cargo xtask i18n` without failing the build, while /// nothing in the complete catalogs may regress. - const COMPLETE: &[&str] = &["ja", "zh"]; + const COMPLETE: &[&str] = &["cs", "ja", "zh"]; /// `text()` falls back to English silently, so a message a complete locale never received is /// shown in English inside an otherwise translated UI, with nothing in the build to say so. @@ -315,6 +317,7 @@ mod tests { #[test] fn newly_catalogued_command_labels_translate() { let cases = [ + ("cs", "Příruba", "Zebra analýza", "Studie pohybu"), ("zh", "法兰", "斑马纹分析", "运动研究"), ("ja", "フランジ", "ゼブラ解析", "モーション スタディ"), ("de", "Flansch", "Zebra-Analyse", "Bewegungsuntersuchung"), From fd073620aedb43dda7e281acb1b80742b7f40cf6 Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:52:41 -0400 Subject: [PATCH 117/149] Sketch: Explode Text turns a text's outlines into ordinary curves (#1842) (#1921) Thanks @DuBearte. --- assets/locales/de.json | 1 + assets/locales/en.json | 1 + assets/locales/ja.json | 1 + assets/locales/ko.json | 1 + assets/locales/zh.json | 1 + crates/ui-egui/src/context_menu.rs | 9 ++++++ crates/ui-egui/src/sketch_text.rs | 46 ++++++++++++++++++++++++++++++ docs/book/src/guide/sketching.md | 5 ++++ 8 files changed, 65 insertions(+) diff --git a/assets/locales/de.json b/assets/locales/de.json index 50cd7a9d..73b59ba0 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -32,6 +32,7 @@ "Duration": "Dauer", "Edges": "Kanten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", + "Explode Text": "Text auflösen", "Export Animation…": "Animation exportieren…", "Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren", "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", diff --git a/assets/locales/en.json b/assets/locales/en.json index b9897704..f29d6140 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -98,6 +98,7 @@ "Enable rule": "Enable rule", "Enabled": "Enabled", "Explode": "Explode", + "Explode Text": "Explode Text", "Export Animation…": "Export Animation…", "Export Current View as DXF": "Export Current View as DXF", "Export Current View as DXF…": "Export Current View as DXF…", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index a9483a34..823200df 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -98,6 +98,7 @@ "Enable rule": "ルールを有効化", "Enabled": "有効", "Explode": "分解", + "Explode Text": "テキストを分解", "Export Animation…": "アニメーションをエクスポート…", "Export Current View as DXF": "現在の表示をDXFでエクスポート", "Export Current View as DXF…": "現在の表示をDXFでエクスポート…", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 43a55ee1..f6438fef 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -32,6 +32,7 @@ "Duration": "지속 시간", "Edges": "모서리", "Edit Profile Sketch": "윤곽 스케치 편집", + "Explode Text": "텍스트 분해", "Export Animation…": "애니메이션 내보내기…", "Export Current View as DXF": "현재 뷰를 DXF로 내보내기", "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index df2c00d6..1dac15f9 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -98,6 +98,7 @@ "Enable rule": "启用规则", "Enabled": "已启用", "Explode": "爆炸", + "Explode Text": "分解文字", "Export Animation…": "导出动画…", "Export Current View as DXF": "将当前视图导出为DXF", "Export Current View as DXF…": "将当前视图导出为DXF…", diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 5bd9d269..db0ad1ad 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -719,6 +719,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { v } +/// Explode Text (#1842): the selected text's outlines become normal sketch lines, arcs and +/// curves, no longer tied to the text, so they can be picked, trimmed and edited one by one. +fn explode_text(app: &SolveApp) -> Option { + let link = crate::sketch_text::selected_link(app)?; + Some(cmd(app, "sketch.break_link", "Explode Text").with(json!({ "link": link }))) +} + /// Sketch entity items: construction, fix, delete, dimension and the constraints the selection /// can take. fn sketch_entity_items(app: &SolveApp) -> Vec { @@ -754,6 +761,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { if curves.is_empty() && points.is_empty() { if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); + v.extend(explode_text(app)); } v.push(cmd(app, "sketch.line", "Line")); v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle")); @@ -773,6 +781,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { } if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); + v.extend(explode_text(app)); } v.push(cmd(app, "sketch.move", "Move/Copy").key("M")); v.push(act("ui.cut", "Cut", "").key("Ctrl+X")); diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index e03df889..3eb30bdc 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -183,6 +183,13 @@ pub(crate) fn selected_entity(app: &SolveApp) -> Option { selected } +/// The link of the one text the selection belongs to (see [`selected_entity`]). +pub(crate) fn selected_link(app: &SolveApp) -> Option { + let entity = selected_entity(app)?; + let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?; + sk.curves.iter().find(|curve| curve.id == entity)?.link.clone() +} + /// Show every outline affected by the open editor without changing feature selections. pub(crate) fn highlighted(app: &SolveApp) -> Vec { let Some(Kind::Text(tx)) = app.dialog.as_ref().map(|dialog| &dialog.kind) else { return Vec::new() }; @@ -752,6 +759,45 @@ mod tests { assert!(highlighted(&h.app).is_empty()); } + /// Explode Text (#1842): the text's outlines stay where they are as ordinary sketch curves, + /// the text itself goes, and one undo brings it back. + #[test] + fn explode_text_leaves_its_outlines_as_normal_curves() { + let (mut h, link, _) = setup(); + h.app.run("sketch.edit_text", json!({"link":link,"text":"TO"})).unwrap(); + let sid = h.app.session.active_sketch.unwrap(); + let outline = |h: &Harness| { + let sk = h.app.session.doc.sketch(sid).unwrap(); + let mut ids: Vec<(String, Option)> = sk.curves.iter().map(|c| (c.id.clone(), c.link.clone())).collect(); + ids.sort(); + ids + }; + let before = outline(&h); + assert!(before.len() > 4 && before.iter().all(|(_, l)| l.as_deref() == Some(link.as_str()))); + let profiles = h.app.session.world_state().sketch(sid).unwrap().profiles.len(); + h.app.session.selection = vec![Sel::SketchCurve { id: before[0].0.clone() }]; + let undo = h.app.session.undo.len(); + let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport) + .into_iter() + .find(|item| item.label == "Explode Text") + .expect("Explode Text is offered for a selected text"); + assert!(item.enabled); + crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO); + h.frames(2); + let after = outline(&h); + assert_eq!(after.iter().map(|c| &c.0).collect::>(), before.iter().map(|c| &c.0).collect::>(), "every outline stays"); + assert!(after.iter().all(|(_, l)| l.is_none()), "no outline belongs to the text"); + let sk = h.app.session.doc.sketch(sid).unwrap(); + assert!(sk.link(&link).is_none(), "the text is gone"); + assert_eq!(h.app.session.world_state().sketch(sid).unwrap().profiles.len(), profiles, "the letters still make the same profiles"); + assert_eq!(h.app.session.undo.len(), undo + 1); + // No longer text: nothing to edit or explode. + assert!(selected_entity(&h.app).is_none()); + assert!(!crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport).iter().any(|item| item.label == "Explode Text")); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(outline(&h), before, "undo brings the text back"); + } + #[test] fn edit_text_tool_accepts_a_letter_interior_without_preselection() { let (mut h, link, _) = setup(); diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 3ca3c4ef..18f82b38 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -66,6 +66,11 @@ keeps the original text. Position and height use the document's units. The four expressions: one that names a parameter (`plate_width / 2`) stays an expression, is shown as typed when the dialog is opened again, and keeps the text following the parameter. +To work on the letters' shapes themselves, right-click the text and choose **Explode Text** +(`sketch.break_link` with the text's `link`). Its outlines stay where they are as ordinary +lines, arcs and curves that can be picked, trimmed, dimensioned and extruded one by one; the +text itself is gone, so it can no longer be edited as text or follow parameters. Undo brings it back. + A text can follow a curve of its sketch (its **Path**). That curve becomes construction: the letters stand on it and their descenders cross it, so as a normal curve it would cut them into pieces, and picking the text would pick those pieces. Taking the text off the curve leaves it From 186555dbe937dd9df4391123b6fb073d5bdfd293 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:54:39 +0200 Subject: [PATCH 118/149] Clearance report: overlaps, near pairs and nearest bodies (#1900) Thanks @NOWN-DEV. --- Cargo.lock | 1 + assets/locales/en.json | 3 + assets/locales/ja.json | 3 + assets/locales/zh.json | 3 + crates/doc/src/eval.rs | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/clearance.rs | 456 +++++++++++++++++++++++ crates/engine/src/cmd/clearance_tests.rs | 253 +++++++++++++ crates/engine/src/cmd/joints.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/geom/src/bvh.rs | 47 +++ crates/geom/src/lib.rs | 1 + crates/geom/src/mesh_distance.rs | 261 +++++++++++++ crates/kernel/src/distance.rs | 312 ++++++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/surface_kind.rs | 8 +- crates/ui-egui/src/dialogs_assembly.rs | 66 +++- docs/book/src/guide/assemblies.md | 20 +- 18 files changed, 1428 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/clearance.rs create mode 100644 crates/engine/src/cmd/clearance_tests.rs create mode 100644 crates/geom/src/mesh_distance.rs create mode 100644 crates/kernel/src/distance.rs diff --git a/Cargo.lock b/Cargo.lock index 7aca27e2..82a4f865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,6 +4135,7 @@ dependencies = [ "solidcraft-render", "solidcraft-sketch", "thiserror 2.0.21", + "web-time", ] [[package]] diff --git a/assets/locales/en.json b/assets/locales/en.json index f29d6140..de3d68aa 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -157,6 +157,7 @@ "Move earlier": "Move earlier", "Move later": "Move later", "Name": "Name", + "Nearest": "Nearest", "New study": "New study", "No crash reports.": "No crash reports.", "No matching appearances": "No matching appearances", @@ -275,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.", "To Offset": "To Offset", "Tolerance": "Tolerance", + "Too close": "Too close", "Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.", "Type": "Type", "UNTRIM": "UNTRIM", @@ -408,6 +410,7 @@ "cmd.file_slicers": "Installed Slicers", "cmd.inspect_accessibility": "Accessibility Analysis", "cmd.inspect_center_of_mass": "Center of Mass", + "cmd.inspect_clearance": "Clearance", "cmd.inspect_compare": "Compare Bodies", "cmd.inspect_continuity": "Continuity Analysis", "cmd.inspect_curvature_comb": "Curvature Comb Analysis", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 823200df..8a6f8a57 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -157,6 +157,7 @@ "Move earlier": "前へ移動", "Move later": "後へ移動", "Name": "名前", + "Nearest": "最も近い", "New study": "新規スタディ", "No crash reports.": "クラッシュレポートはありません。", "No matching appearances": "一致する外観がありません", @@ -275,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。", "To Offset": "オフセットまで", "Tolerance": "公差", + "Too close": "近すぎる", "Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。", "Type": "タイプ", "UNTRIM": "トリム解除", @@ -408,6 +410,7 @@ "cmd.file_slicers": "インストール済みのスライサー", "cmd.inspect_accessibility": "アクセシビリティ解析", "cmd.inspect_center_of_mass": "重心", + "cmd.inspect_clearance": "クリアランス", "cmd.inspect_compare": "ボディを比較", "cmd.inspect_continuity": "連続性解析", "cmd.inspect_curvature_comb": "曲率コーム解析", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 1dac15f9..7cbf9d21 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -157,6 +157,7 @@ "Move earlier": "向前移动", "Move later": "向后移动", "Name": "名称", + "Nearest": "最近", "New study": "新建研究", "No crash reports.": "没有崩溃报告。", "No matching appearances": "没有匹配的外观", @@ -275,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。", "To Offset": "偏移", "Tolerance": "公差", + "Too close": "过近", "Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。", "Type": "类型", "UNTRIM": "取消修剪", @@ -408,6 +410,7 @@ "cmd.file_slicers": "已安装的切片软件", "cmd.inspect_accessibility": "可达性分析", "cmd.inspect_center_of_mass": "质心", + "cmd.inspect_clearance": "间隙", "cmd.inspect_compare": "比较实体", "cmd.inspect_continuity": "连续性分析", "cmd.inspect_curvature_comb": "曲率梳分析", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e626f98b..649f6b60 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -145,8 +145,8 @@ impl ModelBody { Some((Arc::new(plan), Arc::new(mesh))) } - /// The display mesh's chord tolerance: 1/1000 of the body's size. - fn display_tol(&self) -> f64 { + /// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm. + pub fn display_tol(&self) -> f64 { (self.body.size() * 1e-3).max(1e-3) } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d54bfd..58ab39c2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,3 +19,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +web-time = { workspace = true } diff --git a/crates/engine/src/cmd/clearance.rs b/crates/engine/src/cmd/clearance.rs new file mode 100644 index 00000000..add1b24f --- /dev/null +++ b/crates/engine/src/cmd/clearance.rs @@ -0,0 +1,456 @@ +//! Clearance (`inspect.clearance`): in one call, which bodies overlap and by how much, which +//! come closer than a clearance and where, and each body's nearest neighbours. Pairs are pruned +//! by bounding boxes grown by the clearance, then by the bodies' meshes (which lie within their +//! chord tolerance of the faces); distances are measured on the exact faces and edges +//! ([`solidcraft_kernel::body_distance`]) and overlaps by intersecting the bodies. The pairs +//! that are left run in parallel. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use solidcraft_geom::mesh_distance::box_gap; +use solidcraft_geom::{Aabb3, Mesh}; +use solidcraft_kernel::{BodyDistance, DistanceShape}; + +use super::CommandSpec; +use crate::params::{bad, num, str_, string_list}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).noundo().params( + "bodies?: [names] (default: every solid body, in world placement, but those in `against`); against?: [names] (only pairs with one body in `bodies` and the other here); \ + min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \ + nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \ + \"component\" (pairs of bodies inside one placed component, its subcomponents included) | null; tolerance?: mm (default 1e-6: a gap \ + below it is contact, 0) → overlaps: [{a, b, volume_mm3, area_mm2, bbox, center}]; near: [{a, b, distance_mm, from, to, overlap_mm3?: \ + a contact's shared volume below min_volume, exact?: false when measured on the meshes}], nearest by body (the same for pairs that overlap: 0); \ + nearest: {body: [{body, distance_mm}]}; unchecked: [{a, b, reason}] (pairs whose overlap could not be worked out); pairs_checked; \ + pairs_pruned (ruled out by their boxes); ms. Distances are measured on the exact faces and edges, the meshes only rule pairs out", +)]; + +/// Most bodies one call takes. +const MAX_BODIES: usize = 20_000; +/// Most pairs whose grown boxes meet that one call measures. +const MAX_PAIRS: usize = 200_000; +/// Most neighbours a body's `nearest` list holds. +const MAX_NEAREST: usize = 1000; +/// Largest clearance (mm). +const MAX_CLEARANCE: f64 = 1e6; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SkipWithin { + Nothing, + Occurrence, + Component, +} + +/// What one pair came to. +#[derive(Default)] +struct PairResult { + /// Shared volume, area, box and centre. + overlap: Option<(f64, f64, Aabb3, solidcraft_geom::Vec3)>, + distance: Option, + unchecked: Option, +} + +fn clearance(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.clearance"; + let started = web_time::Instant::now(); + let param = |k: &str, default: f64, max: f64| -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(_) => match num(p, k) { + Some(v) if (0.0..=max).contains(&v) => Ok(v), + _ => Err(bad(cmd, format!("`{k}` must be a number from 0 to {max}"))), + }, + } + }; + let min_volume = param("min_volume", 0.01, f64::MAX)?; + let gap = param("clearance", 0.0, MAX_CLEARANCE)?; + let tolerance = param("tolerance", 1e-6, 1.0)?; + let nearest = param("nearest", 0.0, MAX_NEAREST as f64)?.floor() as usize; + let skip = match p.get("skip_within") { + None | Some(Value::Null) => SkipWithin::Nothing, + Some(_) => match str_(p, "skip_within") { + Some("occurrence") => SkipWithin::Occurrence, + Some("component") => SkipWithin::Component, + _ => return Err(bad(cmd, "`skip_within` must be \"occurrence\", \"component\" or null")), + }, + }; + + let st = s.world_state(); + let solid: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect(); + let named = |key: &str| -> Result> { + let names = string_list(p, key); + let mut out = Vec::new(); + for n in &names { + let i = solid.iter().position(|b| &b.name == n).ok_or_else(|| bad(cmd, format!("no solid body `{n}`")))?; + if !out.contains(&i) { + out.push(i); + } + } + Ok(out) + }; + let against = if p.get("against").is_none_or(Value::is_null) { None } else { Some(named("against")?) }; + // Without `bodies`: every body (every body not in `against`, when it is given). + let subjects = if p.get("bodies").is_none_or(Value::is_null) { + (0..solid.len()).filter(|i| against.as_ref().is_none_or(|a| !a.contains(i))).collect() + } else { + named("bodies")? + }; + if solid.len() > MAX_BODIES { + return Err(bad(cmd, format!("at most {MAX_BODIES} bodies"))); + } + let n = solid.len(); + let mut in_subjects = vec![false; n]; + for &i in &subjects { + if let Some(x) = in_subjects.get_mut(i) { + *x = true; + } + } + let mut in_against = vec![false; n]; + for &i in against.iter().flatten() { + if let Some(x) = in_against.get_mut(i) { + *x = true; + } + } + let used: Vec = (0..n).map(|i| in_subjects.get(i).copied().unwrap_or(false) || in_against.get(i).copied().unwrap_or(false)).collect(); + + // The occurrence path of each body, for `skip_within`. + let paths: HashMap> = s.doc.body_frames(&s.model.state()).into_iter().map(|(name, _, path, _)| (name, path)).collect(); + let path_by_body: Vec<&[u64]> = solid.iter().map(|b| paths.get(&b.name).map(Vec::as_slice).unwrap_or(&[])).collect(); + let path_of = |i: usize| path_by_body.get(i).copied().unwrap_or(&[]); + let flag = |v: &[bool], i: usize| v.get(i).copied().unwrap_or(false); + let allowed = |i: usize, j: usize| -> bool { + if i == j { + return false; + } + let paired = match &against { + None => flag(&in_subjects, i) && flag(&in_subjects, j), + Some(_) => (flag(&in_subjects, i) && flag(&in_against, j)) || (flag(&in_subjects, j) && flag(&in_against, i)), + }; + if !paired { + return false; + } + let (a, b) = (path_of(i), path_of(j)); + match skip { + SkipWithin::Nothing => true, + SkipWithin::Occurrence => a.is_empty() || a != b, + SkipWithin::Component => a.is_empty() || a.first() != b.first(), + } + }; + + // Meshes (made once, in parallel) and the distance shapes over them. + let idx: Vec = (0..n).filter(|&i| flag(&used, i)).collect(); + let meshes: Vec>> = { + let made = solidcraft_kernel::map_with_local_pools(&idx, usize::MAX, |&i| Ok(solid.get(i).map(|b| b.mesh()))); + let mut out = vec![None; n]; + for (&i, m) in idx.iter().zip(made) { + if let (Some(slot), Ok(m)) = (out.get_mut(i), m) { + *slot = m; + } + } + out + }; + let shapes: Vec> = (0..n) + .map(|i| match (solid.get(i), meshes.get(i).and_then(Option::as_ref)) { + (Some(b), Some(m)) if flag(&used, i) => Some(DistanceShape::new(&b.body, m, b.display_tol())), + _ => None, + }) + .collect(); + let shape = |i: usize| shapes.get(i).and_then(Option::as_ref); + let boxes: Vec = (0..n).map(|i| shape(i).map_or(Aabb3::EMPTY, DistanceShape::bounds)).collect(); + + // Pairs whose boxes, grown by half the clearance each, meet. + let half = solidcraft_geom::Vec3::new(1.0, 1.0, 1.0) * (gap / 2.0); + let grown: Vec = boxes.iter().map(|b| if b.is_empty() { *b } else { Aabb3 { min: b.min - half, max: b.max + half } }).collect(); + let (close, truncated) = super::joints::overlapping_pairs(&grown, MAX_PAIRS); + let close: Vec<(usize, usize)> = close.into_iter().filter(|&(i, j)| allowed(i, j) && shape(i).is_some() && shape(j).is_some()).collect(); + let mut total = 0usize; + for i in 0..n { + for j in i + 1..n { + if allowed(i, j) { + total += 1; + } + } + } + + let results = solidcraft_kernel::map_with_local_pools(&close, usize::MAX, |&(i, j)| { + let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(PairResult::default()) }; + let (Some(ba), Some(bb)) = (solid.get(i), solid.get(j)) else { return Ok(PairResult::default()) }; + Ok(measure_pair(ba, a, bb, b, gap, min_volume, tolerance)) + }); + + let mut known: HashMap<(usize, usize), f64> = HashMap::new(); + let (mut overlaps, mut near, mut unchecked) = (Vec::new(), Vec::new(), Vec::new()); + let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str()); + for (&(i, j), r) in close.iter().zip(results) { + let r = match r { + Ok(r) => r, + Err(e) => { + unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()})); + continue; + } + }; + if let Some(reason) = r.unchecked { + unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason})); + continue; + } + if let Some((volume, area, bbox, center)) = r.overlap + && volume >= min_volume + { + overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area, + "bbox": {"min": bbox.min, "max": bbox.max}, "center": center})); + known.insert((i, j), 0.0); + continue; + } + let Some(d) = r.distance else { continue }; + known.insert((i, j), d.distance); + if d.distance < gap { + let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to}); + if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) { + e["overlap_mm3"] = json!(v); + } + if !d.exact { + e["exact"] = json!(false); + } + near.push(e); + } + } + near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0))); + + let mut nearest_out = serde_json::Map::new(); + if nearest > 0 { + let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| { + solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| { + let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(None) }; + Ok(solidcraft_kernel::body_distance(a, b, f64::INFINITY)?.map(|d| if d.distance < tolerance { 0.0 } else { d.distance })) + }) + }); + for (i, list) in lists { + let rows: Vec = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect(); + nearest_out.insert(name(i).to_string(), Value::Array(rows)); + } + } + + Ok(json!({ + "overlaps": overlaps, + "near": near, + "nearest": nearest_out, + "unchecked": unchecked, + "bodies": subjects.len(), + "pairs_checked": close.len(), + "pairs_pruned": total.saturating_sub(close.len()), + "truncated": truncated > 0, + "ms": started.elapsed().as_secs_f64() * 1000.0, + })) +} + +/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance +/// when it may be closer than `gap`. +fn measure_pair( + ba: &solidcraft_doc::ModelBody, + a: &DistanceShape, + bb: &solidcraft_doc::ModelBody, + b: &DistanceShape, + gap: f64, + min_volume: f64, + tolerance: f64, +) -> PairResult { + let mut out = PairResult::default(); + let slack = 2.0 * (a.chord() + b.chord()); + let mesh = a.mesh_closest(b, gap + slack); + let holds = |outer: &solidcraft_doc::ModelBody, inner: &solidcraft_doc::ModelBody| { + let (o, i) = (outer.mesh(), inner.mesh()); + let (ob, ib) = (o.bounds(), i.bounds()); + let nested = ob.min.x <= ib.min.x + && ob.min.y <= ib.min.y + && ob.min.z <= ib.min.z + && ib.max.x <= ob.max.x + && ib.max.y <= ob.max.y + && ib.max.z <= ob.max.z; + nested && i.positions.first().is_some_and(|p| o.contains(*p)) + }; + let touching = mesh.is_some_and(|m| m.distance <= slack); + if touching || holds(ba, bb) || holds(bb, ba) { + // A pair whose shared part can't be worked out is reported, not passed as clear. + let region = shared_region(a, b); + let (ca, cb) = (clipped(&ba.body, region), clipped(&bb.body, region)); + let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| { + solidcraft_kernel::boolean(ca.as_ref().unwrap_or(&ba.body), cb.as_ref().unwrap_or(&bb.body), solidcraft_kernel::BoolOp::Intersect) + }); + match shared { + Err(e) => { + out.unchecked = Some(e.to_string()); + return out; + } + Ok(shared) => { + if let Some(reason) = report.unverified() { + out.unchecked = Some(reason); + return out; + } + if let Some(o) = shared { + match measure_near_origin(&o, region.center()) { + Ok(m) if !plausible(m.volume, m.centroid, m.bbox) => { + out.unchecked = Some(format!( + "the shared part came out malformed ({:.4} mm³ with its centre outside its own box), so it is not measured", + m.volume + )); + return out; + } + Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)), + Err(e) => { + out.unchecked = Some(e.to_string()); + return out; + } + } + } + } + } + if out.overlap.is_some_and(|o| o.0 >= min_volume) { + return out; + } + if out.overlap.is_some_and(|o| o.0 > 0.0) { + // Contact with a sliver of shared volume. + let at = out.overlap.map(|o| o.3).unwrap_or_default(); + out.distance = Some(BodyDistance { distance: 0.0, from: at, to: at, exact: true }); + return out; + } + } + if mesh.is_none() { + return out; + } + match solidcraft_kernel::body_distance(a, b, gap) { + Ok(Some(mut d)) => { + if d.distance < tolerance { + d.distance = 0.0; + } + out.distance = Some(d); + } + Ok(None) => {} + Err(e) => out.unchecked = Some(e.to_string()), + } + out +} + +/// The box where two bodies may share volume: where their boxes meet, grown by a margin so that +/// a cut there stays clear of both bodies' faces. +fn shared_region(a: &DistanceShape, b: &DistanceShape) -> Aabb3 { + let (x, y) = (a.bounds(), b.bounds()); + let min = x.min.max(y.min); + let max = x.max.min(y.max); + if x.is_empty() || y.is_empty() || !(min.x <= max.x && min.y <= max.y && min.z <= max.z) { + return Aabb3::EMPTY; + } + let grow = 1.0 + 0.05 * min.dist(max); + let g = solidcraft_geom::Vec3::new(grow, grow, grow); + Aabb3 { min: min - g, max: max + g } +} + +/// `body` cut down to `region` when the body is much larger than it. The boolean works to a +/// tolerance that grows with the bodies' size: a 0.01 mm overlap against a plate 1.7 m long +/// comes out a quarter too small, and thinner ones not at all. Cut to the region first, the +/// body is small and its faces there are the same faces. `None`: use the whole body. +fn clipped(body: &solidcraft_kernel::Body, region: Aabb3) -> Option { + if region.is_empty() || body.size() <= 4.0 * region.diagonal() { + return None; + } + let cut = solidcraft_kernel::box_solid(region.min, region.max).ok()?; + let (r, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(body, &cut, solidcraft_kernel::BoolOp::Intersect)); + match r { + Ok(Some(c)) if report.unverified().is_none() => Some(c), + _ => None, + } +} + +/// [`solidcraft_kernel::measure`] of `body` moved by `-at` and back: far from the origin, the +/// centre of a thin part loses its precision (its moments are large numbers that nearly cancel). +fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3) -> solidcraft_kernel::Result { + let Ok(moved) = solidcraft_kernel::transform(body, -at, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 0.0) else { + return solidcraft_kernel::measure(body); + }; + let mut m = solidcraft_kernel::measure(&moved)?; + m.centroid += at; + m.bbox = Aabb3 { min: m.bbox.min + at, max: m.bbox.max + at }; + Ok(m) +} + +/// Is a measured shared part well formed? A part whose shell came out inside out or open can +/// carry a volume with its centre outside its own box, or more volume than its box holds. +fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool { + let slack = 1e-6 * bbox.diagonal().max(1.0); + let (lo, hi) = (bbox.min, bbox.max); + let inside = [(centre.x, lo.x, hi.x), (centre.y, lo.y, hi.y), (centre.z, lo.z, hi.z)].iter().all(|&(c, l, h)| c >= l - slack && c <= h + slack); + let s = bbox.size(); + volume.is_finite() && volume >= 0.0 && inside && volume <= s.x * s.y * s.z + slack +} + +/// Each subject's `count` nearest bodies, nearest first: pairs are measured in the order of their +/// boxes' gaps (a lower bound of their distance), in rounds, until the boxes of the next ones +/// are farther apart than the last neighbour kept. `known` holds distances already measured. +fn nearest_lists( + subjects: &[usize], + n: usize, + count: usize, + boxes: &[Aabb3], + allowed: &dyn Fn(usize, usize) -> bool, + known: &mut HashMap<(usize, usize), f64>, + measure: impl Fn(&[(usize, usize)]) -> Vec>>, +) -> BTreeMap> { + let key = |i: usize, j: usize| (i.min(j), i.max(j)); + let gap_of = |i: usize, j: usize| match (boxes.get(i), boxes.get(j)) { + (Some(a), Some(b)) => box_gap(a, b), + _ => f64::INFINITY, + }; + // Each subject's candidates, by box gap. + let mut order: BTreeMap, usize)> = BTreeMap::new(); + for &i in subjects { + let mut c: Vec<(f64, usize)> = (0..n).filter(|&j| allowed(i, j)).map(|j| (gap_of(i, j), j)).filter(|(g, _)| g.is_finite()).collect(); + c.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1))); + order.insert(i, (c, 0)); + } + let mut failed: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); + let measured = |known: &HashMap<(usize, usize), f64>, i: usize, c: &[(f64, usize)]| -> Vec<(usize, f64)> { + let mut l: Vec<(usize, f64)> = c.iter().filter_map(|&(_, j)| known.get(&key(i, j)).map(|d| (j, *d))).collect(); + l.sort_by(|x, y| x.1.total_cmp(&y.1).then(x.0.cmp(&y.0))); + l + }; + // Each round measures, for every subject not settled yet, the next `count` candidates. + for _ in 0..n.max(1) { + let mut todo: Vec<(usize, usize)> = Vec::new(); + for (&i, (c, next)) in order.iter_mut() { + let have = measured(known, i, c); + let mut added = 0; + while let Some(&(g, j)) = c.get(*next) { + let bound = if have.len() >= count { have.get(count - 1).map_or(f64::INFINITY, |x| x.1) } else { f64::INFINITY }; + if g > bound || added >= count { + break; + } + *next += 1; + let k = key(i, j); + if !known.contains_key(&k) && !failed.contains(&k) && !todo.contains(&k) { + todo.push(k); + added += 1; + } + } + } + if todo.is_empty() { + break; + } + for (k, r) in todo.iter().zip(measure(&todo)) { + match r { + Ok(Some(d)) => { + known.insert(*k, d); + } + _ => { + failed.insert(*k); + } + } + } + } + order.iter().map(|(&i, (c, _))| (i, measured(known, i, c).into_iter().take(count).collect())).collect() +} + +#[cfg(test)] +#[path = "clearance_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/clearance_tests.rs b/crates/engine/src/cmd/clearance_tests.rs new file mode 100644 index 00000000..5f1876e7 --- /dev/null +++ b/crates/engine/src/cmd/clearance_tests.rs @@ -0,0 +1,253 @@ +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn cube(s: &mut Session, name: &str, size: f64, corner: [f64; 3]) { + run(s, "solid.box", json!({"length": size, "width": size, "height": size, "corner": corner, "operation": "new", "body_name": name})); +} + +fn f(v: &Value) -> f64 { + v.as_f64().unwrap_or(f64::NAN) +} + +/// The pair `{a, b}` of a list entry, sorted. +fn pair(v: &Value) -> [String; 2] { + let mut p = [v["a"].as_str().unwrap_or("").to_string(), v["b"].as_str().unwrap_or("").to_string()]; + p.sort(); + p +} + +/// Two 10 mm cubes sharing a 1 mm corner cube overlap by 1 mm³; a third, far off, is pruned. +#[test] +fn an_overlap_of_one_cubic_millimetre_is_reported() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "B", 10.0, [9.0, 9.0, 9.0]); + cube(&mut s, "Far", 10.0, [100.0, 0.0, 0.0]); + let r = run(&mut s, "inspect.clearance", json!({})); + let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default(); + assert_eq!(overlaps.len(), 1, "{r}"); + assert_eq!(pair(&overlaps[0]), ["A", "B"]); + assert!((f(&overlaps[0]["volume_mm3"]) - 1.0).abs() < 1e-6, "{r}"); + // Three faces of 1 x 1 from each cube: 6 mm². + assert!((f(&overlaps[0]["area_mm2"]) - 6.0).abs() < 1e-6, "{r}"); + assert!((f(&overlaps[0]["center"][0]) - 9.5).abs() < 1e-6, "{r}"); + assert_eq!(r["pairs_checked"], 1, "{r}"); + assert_eq!(r["pairs_pruned"], 2, "{r}"); + assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}"); + // An overlap under min_volume is contact, not an overlap: with a clearance it is near, at 0. + let r = run(&mut s, "inspect.clearance", json!({"min_volume": 2, "clearance": 0.1})); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + assert_eq!(f(&r["near"][0]["distance_mm"]), 0.0, "{r}"); + assert!((f(&r["near"][0]["overlap_mm3"]) - 1.0).abs() < 1e-6, "{r}"); +} + +/// Two cubes 0.5 mm apart are near with a clearance of 1 mm; 2 mm apart they are not. +#[test] +fn pairs_closer_than_the_clearance_are_near() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "B", 10.0, [10.5, 2.0, 3.0]); + cube(&mut s, "C", 10.0, [-12.0, 0.0, 0.0]); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 1})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + assert_eq!(near.len(), 1, "{r}"); + assert_eq!(pair(&near[0]), ["A", "B"]); + assert!((f(&near[0]["distance_mm"]) - 0.5).abs() < 1e-9, "{r}"); + assert!(near[0].get("exact").is_none(), "measured on the exact faces: {r}"); + // The points lie on the facing faces x = 10 and x = 10.5. + let xs = [f(&near[0]["from"][0]), f(&near[0]["to"][0])]; + assert!(xs.iter().any(|x| (x - 10.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.5).abs() < 1e-9), "{r}"); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + // With no clearance nothing is near; with 2.5 mm C (2 mm from A) is near too. + let r = run(&mut s, "inspect.clearance", json!({})); + assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}"); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 2.5})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + assert_eq!(near.len(), 2, "{r}"); + assert_eq!(pair(&near[1]), ["A", "C"]); + assert!((f(&near[1]["distance_mm"]) - 2.0).abs() < 1e-9, "{r}"); +} + +/// Distances where the nearest points are on edges, and on curved faces whose meshes are far +/// coarser than the answer: cube edge to cube edge, a cylinder's side to a cube's face, and a +/// large sphere (mesh chord about 1 mm) to a cube. +#[test] +fn distances_are_exact_on_edges_and_curved_faces() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + // Its vertical edge at (12, 13) faces A's at (10, 10): √(2² + 3²). + cube(&mut s, "B", 10.0, [12.0, 13.0, 0.0]); + run(&mut s, "solid.cylinder", json!({"base": [-30, 5, 0], "radius": 5, "height": 10, "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.sphere", json!({"radius": 500, "center": [5, 5, 510.7], "operation": "new", "body_name": "Ball"})); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"], "bodies": ["B", "Pin", "Ball"]})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + let d = |other: &str| near.iter().find(|n| pair(n).contains(&other.to_string())).map(|n| (f(&n["distance_mm"]), n.get("exact").is_none())); + assert_eq!(near.len(), 3, "{r}"); + let (edge, exact) = d("B").unwrap_or((f64::NAN, false)); + assert!((edge - 13f64.sqrt()).abs() < 1e-6 && exact, "{r}"); + // The pin's side (x = -25) is 25 from A's face x = 0. + let (side, exact) = d("Pin").unwrap_or((f64::NAN, false)); + assert!((side - 25.0).abs() < 1e-6 && exact, "{r}"); + // The ball's lowest point, (5, 5, 10.7), is 0.7 above A's top. + let (ball, exact) = d("Ball").unwrap_or((f64::NAN, false)); + assert!((ball - 0.7).abs() < 1e-6 && exact, "{r}"); + // `against` leaves out the pairs among `bodies`, which are by default every other body. + assert!(near.iter().all(|n| pair(n).contains(&"A".to_string())), "{r}"); + let all = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"]})); + assert_eq!(all["near"].as_array().map(Vec::len), Some(3), "{all}"); +} + +/// A pin in a hole 0.2 mm wider all round, in a plate whose mesh is coarser than that gap: the +/// plate's flat faces reach over the hole on their surfaces but not on the faces, so the +/// distance is the gap to the hole's wall. +#[test] +fn a_pin_in_a_hole_is_as_far_as_the_holes_wall() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "operation": "new", "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 3.2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [10, 10, -5], "operation": "new", "body_name": "Pin"})); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 1})); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + assert!((f(&r["near"][0]["distance_mm"]) - 0.2).abs() < 1e-6, "{r}"); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 0.15})); + assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}"); +} + +/// Three spheres' nearest neighbours against their distances worked out by hand. +#[test] +fn nearest_distances_of_three_spheres() { + let mut s = Session::default(); + for (name, r, c) in [("S1", 5.0, [0.0, 0.0, 0.0]), ("S2", 3.0, [12.0, 0.0, 0.0]), ("S3", 2.0, [3.0, 20.0, 4.0])] { + run(&mut s, "solid.sphere", json!({"radius": r, "center": c, "operation": "new", "body_name": name})); + } + let r = run(&mut s, "inspect.clearance", json!({"nearest": 2})); + let d12 = 12.0 - 8.0; + let d13 = (9.0f64 + 400.0 + 16.0).sqrt() - 7.0; + let d23 = (81.0f64 + 400.0 + 16.0).sqrt() - 5.0; + let want = [("S1", [("S2", d12), ("S3", d13)]), ("S2", [("S1", d12), ("S3", d23)]), ("S3", [("S1", d13), ("S2", d23)])]; + for (body, list) in want { + let got = r["nearest"][body].as_array().cloned().unwrap_or_default(); + assert_eq!(got.len(), 2, "{body}: {r}"); + for (g, (other, d)) in got.iter().zip(list) { + assert_eq!(g["body"], other, "{body}: {r}"); + assert!((f(&g["distance_mm"]) - d).abs() < 1e-6, "{body} to {other}: {} against {d}", g["distance_mm"]); + } + } + // One neighbour each. + let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"]})); + assert_eq!(r["nearest"]["S3"].as_array().map(Vec::len), Some(0), "only S3 is in `bodies`, so it has no partner: {r}"); + let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"], "against": ["S1", "S2"]})); + assert_eq!(r["nearest"]["S3"][0]["body"], "S1", "{r}"); +} + +/// `skip_within` leaves out the pairs inside one component (two bodies made to touch, as the +/// parts of a bought component do) and keeps those between components. +#[test] +fn skip_within_leaves_out_a_components_own_pairs() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Bought"})); + cube(&mut s, "Housing", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "Insert", 4.0, [8.0, 3.0, 3.0]); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Bracket"})); + cube(&mut s, "Plate", 10.0, [-9.5, 0.0, 0.0]); + let all = run(&mut s, "inspect.clearance", json!({})); + let pairs: Vec<[String; 2]> = all["overlaps"].as_array().into_iter().flatten().map(pair).collect(); + assert!(pairs.contains(&["Housing".into(), "Insert".into()]) && pairs.contains(&["Housing".into(), "Plate".into()]), "{all}"); + for mode in ["component", "occurrence"] { + let r = run(&mut s, "inspect.clearance", json!({"skip_within": mode})); + let pairs: Vec<[String; 2]> = r["overlaps"].as_array().into_iter().flatten().map(pair).collect(); + assert_eq!(pairs, vec![["Housing".to_string(), "Plate".to_string()]], "{mode}: {r}"); + assert!((f(&r["overlaps"][0]["volume_mm3"]) - 50.0).abs() < 1e-6, "{r}"); + } +} + +/// Parameters out of range are refused, not guessed at. +#[test] +fn bad_parameters_are_refused() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + for p in [ + json!({"clearance": -1}), + json!({"clearance": "wide"}), + json!({"min_volume": -0.1}), + json!({"nearest": 1e9}), + json!({"tolerance": 5}), + json!({"skip_within": "body"}), + json!({"bodies": ["Nope"]}), + json!({"against": ["Nope"]}), + ] { + assert!(s.execute("inspect.clearance", &p).is_err(), "{p}"); + } + let r = run(&mut s, "inspect.clearance", json!({"nearest": 3, "skip_within": null})); + assert_eq!(r["pairs_checked"], 0, "{r}"); + assert_eq!(r["nearest"]["A"].as_array().map(Vec::len), Some(0), "{r}"); +} + +/// A pin sunk 0.01 mm into a plate 1.7 m long: the shared ring holds π (4.43² − 2.5²) × 0.01 mm³ +/// wherever the plate stands, as on a plate of 100 mm (the boolean of the whole plate gave 0.31). +#[test] +fn a_thin_overlap_with_a_large_plate_is_measured_in_full() { + let ring = std::f64::consts::PI * (4.43f64.powi(2) - 2.5f64.powi(2)) * 0.01; + for (length, far) in [(100.0, false), (1700.0, false), (1700.0, true)] { + let mut s = Session::default(); + let corner = [-length / 2.0, -length / 4.0, 0.0]; + run(&mut s, "solid.box", json!({"length": length, "width": length / 2.0, "height": 5, "corner": corner, "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 2.5, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 7, "base": [0, 0, -1.99], "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.cylinder", json!({"radius": 4.43, "height": 2, "base": [0, 0, 4.99], "operation": "join", "targets": ["Pin"]})); + let shift = if far { [-326.3, 13.5, 1278.3] } else { [0.0; 3] }; + if far { + run(&mut s, "solid.move", json!({"bodies": ["Plate", "Pin"], "translate": shift})); + } + let r = run(&mut s, "inspect.clearance", json!({"min_volume": 1e-4})); + let o = &r["overlaps"][0]; + assert!((f(&o["volume_mm3"]) - ring).abs() < 1e-3 * ring, "{length} {far}: {r}"); + // The ring's centre is on the axis, 0.005 below the plate's face. + assert!((f(&o["center"][2]) - (4.995 + shift[2])).abs() < 1e-4, "{length} {far}: {r}"); + assert!((f(&o["center"][0]) - shift[0]).abs() < 1e-4, "{length} {far}: {r}"); + } +} + +/// A shared part whose centre lies outside its own box, or which holds more than its box, is +/// malformed: the pair is reported unchecked rather than with that volume. +#[test] +fn a_malformed_shared_part_is_not_measured() { + use solidcraft_geom::{Aabb3, Vec3}; + let bbox = Aabb3 { min: Vec3::new(0.0, 0.0, 0.0), max: Vec3::new(10.0, 10.0, 1.0) }; + assert!(super::plausible(50.0, Vec3::new(5.0, 5.0, 0.5), bbox)); + assert!(!super::plausible(50.0, Vec3::new(5.0, 5.0, 3.0), bbox)); + assert!(!super::plausible(1239.0, Vec3::new(5.0, 5.0, 0.5), bbox)); + assert!(!super::plausible(f64::NAN, Vec3::new(5.0, 5.0, 0.5), bbox)); +} + +/// Two plates 0.2 mm apart, the upper one set 10 mm in from the lower one's corner, and two set +/// side by side with a 0.2 mm gap: 0.2, not 0 (a point past a corner, on the line of one of its +/// edges, was taken for a point of the face). Gaps under twice the meshes' chord went wrong. +#[test] +fn a_small_gap_past_a_corner_is_not_contact() { + let mut s = Session::default(); + let plate = |s: &mut Session, name: &str, size: [f64; 3], corner: [f64; 3]| { + run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner, "operation": "new", "body_name": name})); + }; + plate(&mut s, "A", [100.0, 100.0, 2.0], [0.0, 0.0, 0.0]); + plate(&mut s, "B", [100.0, 100.0, 2.0], [10.0, 10.0, 2.2]); + plate(&mut s, "C", [100.0, 100.0, 2.0], [0.0, 0.0, 10.0]); + plate(&mut s, "D", [100.0, 100.0, 2.0], [100.2, 0.0, 10.0]); + // 3 mm apart in x and 0.2 in z, along their long sides: √(3² + 0.2²). + plate(&mut s, "E", [1000.0, 100.0, 2.0], [0.0, 300.0, 0.0]); + plate(&mut s, "F", [1000.0, 100.0, 2.0], [1003.0, 300.0, 2.2]); + for (a, b, want) in [("A", "B", 0.2), ("C", "D", 0.2), ("E", "F", (9.0f64 + 0.04).sqrt())] { + let r = run(&mut s, "inspect.clearance", json!({"bodies": [a], "against": [b], "clearance": 5, "nearest": 4})); + assert!((f(&r["near"][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}"); + assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}"); + } +} diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 09a90321..962af8f8 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -1201,7 +1201,7 @@ const MAX_INTERFERENCE_PAIRS: usize = 20_000; /// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most /// `limit` of them, plus how many more there were. -fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) { +pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) { let mut order: Vec = (0..boxes.len()).collect(); order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) { (Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7380d9d..1a78ddae 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ macro_rules! planned { } mod browser; +mod clearance; mod clipboard; mod compare; mod component; @@ -251,6 +252,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(component::COMMANDS.iter()); v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); + v.extend(clearance::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); v.extend(parts::COMMANDS.iter()); diff --git a/crates/geom/src/bvh.rs b/crates/geom/src/bvh.rs index 24c5dc80..816fa5ad 100644 --- a/crates/geom/src/bvh.rs +++ b/crates/geom/src/bvh.rs @@ -145,6 +145,53 @@ impl Bvh { } best } + + /// Calls `item(i, j)` for pairs of items, `i` from this hierarchy and `j` from `other`, in + /// leaves whose boxes, and every pair of boxes above them, `enter` accepts. `enter` is asked + /// again for a pair of boxes when it is taken up, so a bound that tightens while items are + /// visited (the nearest pair so far) prunes what is still waiting. Of two children, the pair + /// whose box centres are nearer is taken up first. + pub fn visit_pairs(&self, other: &Bvh, mut enter: impl FnMut(&Aabb3, &Aabb3) -> bool, mut item: impl FnMut(u32, u32)) { + if self.nodes.is_empty() || other.nodes.is_empty() { + return; + } + let mut stack = vec![(0usize, 0usize)]; + while let Some((i, j)) = stack.pop() { + let (Some(a), Some(b)) = (self.nodes.get(i), other.nodes.get(j)) else { continue }; + if !enter(&a.bounds, &b.bounds) { + continue; + } + match (a.count > 0, b.count > 0) { + (true, true) => { + let (ia, ib) = (a.first as usize, b.first as usize); + let left = self.order.get(ia..ia + a.count as usize).unwrap_or(&[]); + let right = other.order.get(ib..ib + b.count as usize).unwrap_or(&[]); + for &x in left { + for &y in right { + item(x, y); + } + } + } + // Split the node that is not a leaf, or the larger one. + (leaf_a, leaf_b) => { + let split_a = !leaf_a && (leaf_b || a.bounds.diagonal() >= b.bounds.diagonal()); + let pairs = + if split_a { [(a.first as usize, j), (a.first as usize + 1, j)] } else { [(i, b.first as usize), (i, b.first as usize + 1)] }; + let gap = |(x, y): (usize, usize)| match (self.nodes.get(x), other.nodes.get(y)) { + (Some(x), Some(y)) => x.bounds.center().dist(y.bounds.center()), + _ => f64::INFINITY, + }; + let [p, q] = pairs; + // The nearer pair is popped first. + if gap(p) <= gap(q) { + stack.extend([q, p]); + } else { + stack.extend([p, q]); + } + } + } + } + } } fn finite(b: &Aabb3) -> bool { diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 2ad4f3d8..ab75d895 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -18,6 +18,7 @@ pub mod env; pub mod fill; pub mod fill_general; mod mesh; +pub mod mesh_distance; mod plane; mod profile; pub mod tess; diff --git a/crates/geom/src/mesh_distance.rs b/crates/geom/src/mesh_distance.rs new file mode 100644 index 00000000..31653917 --- /dev/null +++ b/crates/geom/src/mesh_distance.rs @@ -0,0 +1,261 @@ +//! Distances between two triangle meshes: the nearest pair of points, and every pair of +//! triangles within a distance. A [`TriangleSet`] keeps a mesh's triangles under a bounding +//! volume hierarchy, so a query costs about the pairs of triangles that come near each other +//! rather than the product of the triangle counts. + +use crate::compare::closest_on_triangle; +use crate::{Aabb3, Bvh, Mesh, Vec3}; + +/// A mesh's triangles with their B-rep faces, indexed for distance queries. +#[derive(Clone, Debug, Default)] +pub struct TriangleSet { + tris: Vec<[Vec3; 3]>, + face: Vec, + bvh: Bvh, + bounds: Aabb3, +} + +/// The nearest points of two triangle sets, and the triangles they lie on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshClosest { + pub distance: f64, + pub a: Vec3, + pub b: Vec3, + pub tri_a: u32, + pub tri_b: u32, +} + +impl TriangleSet { + /// The set of `mesh`'s triangles (ones with a vertex that is not finite are left out). + pub fn new(mesh: &Mesh) -> TriangleSet { + let mut tris = Vec::with_capacity(mesh.triangles.len()); + let mut face = Vec::with_capacity(mesh.triangles.len()); + let mut boxes = Vec::with_capacity(mesh.triangles.len()); + let mut bounds = Aabb3::EMPTY; + for (k, t) in mesh.triangles.iter().enumerate() { + let Some(p) = mesh.tri(t) else { continue }; + if !p.iter().all(|v| v.is_finite()) { + continue; + } + let mut b = Aabb3::EMPTY; + for v in p { + b.add(v); + } + bounds = bounds.union(&b); + boxes.push(b); + tris.push(p); + face.push(mesh.tri_face.get(k).copied().unwrap_or(0)); + } + TriangleSet { bvh: Bvh::new(&boxes), tris, face, bounds } + } + + /// The box around every triangle. + pub fn bounds(&self) -> Aabb3 { + self.bounds + } + + pub fn len(&self) -> usize { + self.tris.len() + } + + pub fn is_empty(&self) -> bool { + self.tris.is_empty() + } + + /// Triangle `t`'s corners. + pub fn triangle(&self, t: u32) -> Option<[Vec3; 3]> { + self.tris.get(t as usize).copied() + } + + /// The B-rep face triangle `t` came from. + pub fn face(&self, t: u32) -> Option { + self.face.get(t as usize).copied() + } + + /// The nearest points of this set and `other`, if they come within `limit` of each other + /// (`f64::INFINITY`: always). Triangles that cross count as distance 0, at a point where + /// they meet. + pub fn closest(&self, other: &TriangleSet, limit: f64) -> Option { + let mut best: Option = None; + let bound = std::cell::Cell::new(if limit.is_nan() { f64::INFINITY } else { limit }); + let mut found = |i: u32, j: u32| { + let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return }; + let (d, a, b) = triangle_distance(x, y); + if d <= bound.get() && best.is_none_or(|m| d < m.distance) { + best = Some(MeshClosest { distance: d, a, b, tri_a: i, tri_b: j }); + bound.set(d); + } + }; + self.bvh.visit_pairs(&other.bvh, |x, y| box_gap(x, y) <= bound.get(), &mut found); + best + } + + /// Calls `f` with every pair of triangles, one from this set and one from `other`, that come + /// within `d` of each other, with their nearest points. + pub fn within(&self, other: &TriangleSet, d: f64, mut f: impl FnMut(MeshClosest)) { + if !(d >= 0.0) { + return; + } + self.bvh.visit_pairs( + &other.bvh, + |x, y| box_gap(x, y) <= d, + |i, j| { + let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return }; + let (dist, a, b) = triangle_distance(x, y); + if dist <= d { + f(MeshClosest { distance: dist, a, b, tri_a: i, tri_b: j }); + } + }, + ); + } + + /// The distance from `p` to the nearest of the triangles `tris` (indices into this set). + pub fn distance_to(&self, p: Vec3, tris: &[u32]) -> f64 { + tris.iter() + .filter_map(|&t| self.tris.get(t as usize)) + .map(|[a, b, c]| closest_on_triangle(p, *a, *b, *c).dist(p)) + .fold(f64::INFINITY, f64::min) + } +} + +/// The gap between two boxes (0 when they meet). +pub fn box_gap(a: &Aabb3, b: &Aabb3) -> f64 { + if a.is_empty() || b.is_empty() { + return f64::INFINITY; + } + let axis = |lo1: f64, hi1: f64, lo2: f64, hi2: f64| (lo2 - hi1).max(lo1 - hi2).max(0.0); + let d = Vec3::new(axis(a.min.x, a.max.x, b.min.x, b.max.x), axis(a.min.y, a.max.y, b.min.y, b.max.y), axis(a.min.z, a.max.z, b.min.z, b.max.z)); + d.len() +} + +/// The nearest points of segments `p0`–`p1` and `q0`–`q1` (Ericson, Real-Time Collision +/// Detection, 5.1.9). +pub fn closest_on_segments(p0: Vec3, p1: Vec3, q0: Vec3, q1: Vec3) -> (Vec3, Vec3) { + let (d1, d2, r) = (p1 - p0, q1 - q0, p0 - q0); + let (a, e, f) = (d1.len2(), d2.len2(), d2.dot(r)); + const TINY: f64 = 1e-300; + let (s, t) = if a <= TINY && e <= TINY { + (0.0, 0.0) + } else if a <= TINY { + (0.0, (f / e).clamp(0.0, 1.0)) + } else { + let c = d1.dot(r); + if e <= TINY { + ((-c / a).clamp(0.0, 1.0), 0.0) + } else { + let b = d1.dot(d2); + let den = a * e - b * b; + let mut s = if den > TINY * (a * e).max(1.0) { ((b * f - c * e) / den).clamp(0.0, 1.0) } else { 0.0 }; + let mut t = (b * s + f) / e; + if t < 0.0 { + t = 0.0; + s = (-c / a).clamp(0.0, 1.0); + } else if t > 1.0 { + t = 1.0; + s = ((b - c) / a).clamp(0.0, 1.0); + } + (s, t) + } + }; + (p0 + d1 * s, q0 + d2 * t) +} + +/// Where segment `p`–`q` passes through triangle `t` (not when it lies in its plane). +fn segment_through(p: Vec3, q: Vec3, t: &[Vec3; 3]) -> Option { + let [a, b, c] = *t; + let n = (b - a).cross(c - a); + let (dp, dq) = (n.dot(p - a), n.dot(q - a)); + if (dp > 0.0 && dq > 0.0) || (dp < 0.0 && dq < 0.0) || dp == dq { + return None; + } + let x = p + (q - p) * (dp / (dp - dq)); + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(x - *u).dot(n) >= 0.0); + inside.then_some(x) +} + +/// The distance between two triangles and their nearest points (0 where they cross). +pub fn triangle_distance(s: &[Vec3; 3], t: &[Vec3; 3]) -> (f64, Vec3, Vec3) { + let edges = |x: &[Vec3; 3]| [(x[0], x[1]), (x[1], x[2]), (x[2], x[0])]; + for (p, q) in edges(s) { + if let Some(x) = segment_through(p, q, t) { + return (0.0, x, x); + } + } + for (p, q) in edges(t) { + if let Some(x) = segment_through(p, q, s) { + return (0.0, x, x); + } + } + let mut best = (f64::INFINITY, s[0], t[0]); + let mut keep = |a: Vec3, b: Vec3| { + let d = a.dist(b); + if d < best.0 { + best = (d, a, b); + } + }; + for p in s { + keep(*p, closest_on_triangle(*p, t[0], t[1], t[2])); + } + for q in t { + keep(closest_on_triangle(*q, s[0], s[1], s[2]), *q); + } + for (p0, p1) in edges(s) { + for (q0, q1) in edges(t) { + let (a, b) = closest_on_segments(p0, p1, q0, q1); + keep(a, b); + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tri(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [Vec3; 3] { + [Vec3::from(a), Vec3::from(b), Vec3::from(c)] + } + + /// Distances between triangles worked out by hand: face to vertex, edge to edge, crossing. + #[test] + fn triangle_distances_by_hand() { + let floor = tri([0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]); + // A vertex 3 above the floor's inside. + let (d, a, b) = triangle_distance(&floor, &tri([1.0, 1.0, 3.0], [1.0, 1.0, 9.0], [2.0, 1.0, 9.0])); + assert!((d - 3.0).abs() < 1e-12 && a.dist(Vec3::new(1.0, 1.0, 0.0)) < 1e-12 && b.dist(Vec3::new(1.0, 1.0, 3.0)) < 1e-12); + // Two skew edges: the floor's x axis edge and a line along y at x = 5, z = 2, off the floor at y < 0. + let (d, ..) = triangle_distance(&floor, &tri([5.0, -1.0, 2.0], [5.0, -9.0, 2.0], [5.0, -5.0, 9.0])); + assert!((d - 5f64.sqrt()).abs() < 1e-12, "{d}"); + // A triangle through the floor. + let (d, ..) = triangle_distance(&floor, &tri([2.0, 2.0, -1.0], [2.0, 2.0, 1.0], [3.0, 2.0, 1.0])); + assert_eq!(d, 0.0); + // Parallel segments overlapping along their length. + let (p, q) = closest_on_segments(Vec3::ZERO, Vec3::X * 4.0, Vec3::new(2.0, 1.0, 0.0), Vec3::new(6.0, 1.0, 0.0)); + assert!((p.dist(q) - 1.0).abs() < 1e-12); + } + + fn cube(at: Vec3, size: f64) -> Mesh { + let mut m = Mesh::default(); + let c = |x: f64, y: f64, z: f64| at + Vec3::new(x, y, z) * size; + m.positions = vec![c(0., 0., 0.), c(1., 0., 0.), c(1., 1., 0.), c(0., 1., 0.), c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)]; + m.triangles = + vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; + m.tri_face = vec![0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5]; + m + } + + /// Two cubes apart along a diagonal: the nearest points are their facing corners. + #[test] + fn nearest_points_of_two_meshes() { + let a = TriangleSet::new(&cube(Vec3::ZERO, 10.0)); + let b = TriangleSet::new(&cube(Vec3::new(13.0, 14.0, 0.0), 10.0)); + let c = a.closest(&b, f64::INFINITY); + assert!(c.is_some_and(|c| (c.distance - 5.0).abs() < 1e-12), "{c:?}"); + assert!(a.closest(&b, 4.0).is_none(), "nothing within 4"); + let mut n = 0; + a.within(&b, 5.5, |_| n += 1); + assert!(n > 0); + let far = TriangleSet::new(&cube(Vec3::new(5.0, 5.0, 5.0), 10.0)); + assert_eq!(a.closest(&far, f64::INFINITY).map(|c| c.distance), Some(0.0), "overlapping cubes cross"); + } +} diff --git a/crates/kernel/src/distance.rs b/crates/kernel/src/distance.rs new file mode 100644 index 00000000..29cd5920 --- /dev/null +++ b/crates/kernel/src/distance.rs @@ -0,0 +1,312 @@ +//! The minimum distance between two bodies, on their exact faces and edges. +//! +//! The display meshes find where the bodies come nearest and prune pairs that are far apart: +//! the meshes lie within their chord tolerance of the faces, so a mesh distance bounds the exact +//! one from both sides. The answer itself comes from the exact geometry. Near the nearest mesh +//! points, each body offers the face there and the edges of that face that pass close by; the +//! points are moved in turn to the nearest point of one feature from the other (alternating +//! projection) until they settle, on analytic surfaces in closed form and on other surfaces and +//! curves through the kernel's point inversion. A point that settles off its face (past the +//! face's boundary) is dropped: the face's edges, offered too, then carry the minimum. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use solidcraft_geom::mesh_distance::{MeshClosest, TriangleSet}; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3}; +use crate::surface_kind::{FaceSurface, surface_of}; +use crate::{Result, guard}; + +/// The minimum distance between two bodies and the points where it is reached. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BodyDistance { + pub distance: f64, + /// On the first body. + pub from: Vec3, + /// On the second body. + pub to: Vec3, + /// Measured on the exact faces and edges (false: on the meshes, for a mesh body or where the + /// exact geometry gave no answer). + pub exact: bool, +} + +/// A body made ready for distance queries: its mesh's triangles under a hierarchy, and its faces +/// and edges as they are first needed. Share one between the queries that involve its body. +pub struct DistanceShape<'a> { + body: &'a Body, + mesh: &'a Mesh, + set: TriangleSet, + chord: f64, + faces: Vec, + edges: Vec, + surfaces: Vec>>, + face_tris: OnceLock>>, +} + +/// Most face pairs near the nearest mesh points whose exact distance is worked out. +const MAX_FACE_PAIRS: usize = 24; +/// Most projection rounds for one pair of features. +const MAX_ROUNDS: usize = 400; + +impl<'a> DistanceShape<'a> { + /// `mesh` is `body`'s mesh, within `chord` (mm) of its faces. + pub fn new(body: &'a Body, mesh: &'a Mesh, chord: f64) -> DistanceShape<'a> { + let exact = body.mesh.is_none(); + let faces: Vec = if exact { body.solid.face_iter().cloned().collect() } else { Vec::new() }; + let edges = if exact { Body::unique_edges(&body.solid) } else { Vec::new() }; + let surfaces = faces.iter().map(|_| OnceLock::new()).collect(); + let chord = if chord.is_finite() && chord > 0.0 { chord } else { 0.0 }; + DistanceShape { body, mesh, set: TriangleSet::new(mesh), chord, faces, edges, surfaces, face_tris: OnceLock::new() } + } + + /// The box around the body: its mesh's, grown by the chord tolerance. + pub fn bounds(&self) -> Aabb3 { + let b = self.set.bounds(); + if b.is_empty() { + return b; + } + let g = Vec3::new(1.0, 1.0, 1.0) * self.chord; + Aabb3 { min: b.min - g, max: b.max + g } + } + + /// How far the mesh may lie from the faces (mm). + pub fn chord(&self) -> f64 { + self.chord + } + + /// The nearest points of the two meshes, if they come within `limit`. + pub fn mesh_closest(&self, other: &DistanceShape, limit: f64) -> Option { + self.set.closest(&other.set, limit) + } + + fn surface(&self, face: usize) -> Option { + *self.surfaces.get(face)?.get_or_init(|| self.faces.get(face).and_then(|f| surface_of(f, self.body.size()))) + } + + fn face_triangles(&self, face: u32) -> &[u32] { + let map = self.face_tris.get_or_init(|| { + let mut m: HashMap> = HashMap::new(); + for t in 0..self.set.len() as u32 { + if let Some(f) = self.set.face(t) { + m.entry(f).or_default().push(t); + } + } + m + }); + map.get(&face).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Does `p`, a point on face `face`'s surface, lie on the face itself? Its mesh rules out + /// points farther than twice the chord. Nearer the face's boundary than that, the exact edge + /// nearest `p` decides: `p` must lie on the side of it where the face's mesh is. + fn on_face(&self, face: u32, p: Vec3) -> bool { + let tris = self.face_triangles(face); + let tiny = 1e-9 * self.body.size().max(1.0); + let reach = 2.0 * self.chord + tiny; + if self.set.distance_to(p, tris) > reach { + return false; + } + // The exact edges of the face near `p`: their nearest points and directions there. + let mut near: Vec<(f64, Vec3, Vec3)> = Vec::new(); + for e in self.mesh.face_edges(face) { + let Some(line) = self.mesh.edges.get(e) else { continue }; + let rough = match line.as_slice() { + [one] => one.dist(p), + _ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), + }; + if rough > 2.0 * reach { + continue; + } + if let Some((at, dir)) = self.edge_nearest(e, p, &mut Hint::default()) { + near.push((at.dist(p), at, dir)); + } + } + let Some(least) = near.iter().map(|n| n.0).reduce(f64::min) else { return true }; + if least <= tiny { + return true; + } + // The nearest edge (any of them, where several meet at a vertex) with the face's mesh on + // `p`'s side. An edge with the face on both sides (a seam inside it) says nothing. + let mut decided = false; + for &(_, at, dir) in near.iter().filter(|n| n.0 <= least + tiny) { + let Some(t) = dir.normalized() else { continue }; + let across = |x: Vec3| { + let v = x - at; + v - t * v.dot(t) + }; + let sides: Vec = tris + .iter() + .filter_map(|&k| self.set.triangle(k)) + .filter(|[a, b, c]| solidcraft_geom::compare::closest_on_triangle(at, *a, *b, *c).dist(at) <= reach) + .map(|[a, b, c]| across((a + b + c) / 3.0)) + .filter(|w| w.len() > tiny) + .collect(); + let Some(inward) = sides.iter().copied().max_by(|x, y| x.len().total_cmp(&y.len())) else { continue }; + if sides.iter().any(|w| w.dot(inward) < 0.0) { + continue; + } + // `p` straight along the edge (past its end, on the line through it) says nothing + // about which side it is on: another edge meeting there decides. + let off = across(p); + if off.len() <= tiny { + continue; + } + decided = true; + if off.dot(inward) >= 0.0 { + return true; + } + } + !decided + } + + /// The features near mesh point `p` on face `face`: the face, and its edges within twice the chord. + fn features(&self, face: u32, p: Vec3) -> Vec { + let mut out = vec![Feature::Face(face as usize)]; + let reach = 2.0 * self.chord + 1e-9 * self.body.size().max(1.0); + for e in self.mesh.face_edges(face) { + let Some(line) = self.mesh.edges.get(e) else { continue }; + let near = match line.as_slice() { + [one] => one.dist(p), + _ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), + }; + if near <= reach && e < self.edges.len() { + out.push(Feature::Edge(e)); + } + } + out + } + + /// The point of `f` nearest `p`, with the parameter hint for the next call. + fn project(&self, f: Feature, p: Vec3, hint: &mut Hint) -> Option { + use mt::{ParametricSurface, SearchNearestParameter}; + match f { + Feature::Face(i) => { + if let Some(q) = self.surface(i).and_then(|s| s.nearest(p)) { + return Some(q); + } + let s = self.faces.get(i)?.oriented_surface(); + let uv = s.search_nearest_parameter(p3(p), hint.uv, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?; + hint.uv = Some(uv); + Some(from_p3(s.subs(uv.0, uv.1))).filter(|q| q.is_finite()) + } + Feature::Edge(i) => self.edge_nearest(i, p, hint).map(|(q, _)| q), + } + } + + /// The point of edge `i` nearest `p`, with the edge's direction there. + fn edge_nearest(&self, i: usize, p: Vec3, hint: &mut Hint) -> Option<(Vec3, Vec3)> { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let c = self.edges.get(i)?.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let (lo, hi) = (t0.min(t1), t0.max(t1)); + let at = |t: f64| from_p3(c.subs(t)); + let start = match hint.t { + Some(t) => t, + None => { + // A coarse look along the curve, then the inversion from the best sample. + let n = 32; + (0..=n).map(|k| t0 + (t1 - t0) * f64::from(k) / f64::from(n)).min_by(|a, b| at(*a).dist(p).total_cmp(&at(*b).dist(p)))? + } + }; + let mut best = (start.clamp(lo, hi), f64::INFINITY); + best.1 = at(best.0).dist(p); + for t in [c.search_nearest_parameter(p3(p), Some(start), 64).map(|t| t.clamp(lo, hi)), Some(t0), Some(t1)].into_iter().flatten() { + let d = at(t).dist(p); + if d < best.1 { + best = (t, d); + } + } + hint.t = Some(best.0); + let d = c.der(best.0); + let q = at(best.0); + q.is_finite().then_some((q, Vec3::new(d.x, d.y, d.z))) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Feature { + Face(usize), + Edge(usize), +} + +#[derive(Clone, Copy, Default)] +struct Hint { + uv: Option<(f64, f64)>, + t: Option, +} + +/// Settle a pair of points on features `fa` of `a` and `fb` of `b`, starting from `pb` on `b`. +fn settle(a: &DistanceShape, fa: Feature, b: &DistanceShape, fb: Feature, pb: Vec3) -> Option<(f64, Vec3, Vec3)> { + let (mut ha, mut hb) = (Hint::default(), Hint::default()); + let scale = a.body.size().max(b.body.size()).max(1.0); + let mut q = b.project(fb, pb, &mut hb)?; + let mut p = a.project(fa, q, &mut ha)?; + for _ in 0..MAX_ROUNDS { + let q2 = b.project(fb, p, &mut hb)?; + let p2 = a.project(fa, q2, &mut ha)?; + let moved = p2.dist(p).max(q2.dist(q)); + p = p2; + q = q2; + if moved <= 1e-12 * scale { + break; + } + } + let d = p.dist(q); + d.is_finite().then_some((d, p, q)) +} + +/// The minimum distance between the bodies of `a` and `b`, when it may be within `limit` (mm; +/// `f64::INFINITY` for any): `None` when the meshes show it is not. Overlapping bodies are not +/// told apart from touching ones here (both give 0 or the distance between their surfaces): +/// an intersection says whether they share volume. +pub fn body_distance(a: &DistanceShape, b: &DistanceShape, limit: f64) -> Result> { + let slack = 2.0 * (a.chord + b.chord); + let Some(m) = a.mesh_closest(b, if limit.is_finite() { limit + slack } else { f64::INFINITY }) else { return Ok(None) }; + let mesh = BodyDistance { distance: m.distance, from: m.a, to: m.b, exact: false }; + if a.faces.is_empty() || b.faces.is_empty() { + return Ok(Some(mesh)); + } + // The face pairs near the nearest mesh points, each with its nearest triangle pair. + let mut seeds: HashMap<(u32, u32), MeshClosest> = HashMap::new(); + a.set.within(&b.set, m.distance + slack, |c| { + let (Some(fa), Some(fb)) = (a.set.face(c.tri_a), b.set.face(c.tri_b)) else { return }; + let e = seeds.entry((fa, fb)).or_insert(c); + if c.distance < e.distance { + *e = c; + } + }); + let mut seeds: Vec<((u32, u32), MeshClosest)> = seeds.into_iter().collect(); + seeds.sort_by(|x, y| x.1.distance.total_cmp(&y.1.distance).then(x.0.cmp(&y.0))); + seeds.truncate(MAX_FACE_PAIRS); + let best = guard("body distance", || { + let mut best: Option = None; + let mut tried = std::collections::HashSet::new(); + for ((fa, fb), c) in &seeds { + for xa in a.features(*fa, c.a) { + for xb in b.features(*fb, c.b) { + if !tried.insert((xa, xb)) { + continue; + } + let Some((d, p, q)) = settle(a, xa, b, xb, c.b) else { continue }; + let holds = |s: &DistanceShape, f: Feature, x: Vec3| match f { + Feature::Face(i) => s.on_face(i as u32, x), + Feature::Edge(_) => true, + }; + if holds(a, xa, p) && holds(b, xb, q) && best.is_none_or(|bd| d < bd.distance) { + best = Some(BodyDistance { distance: d, from: p, to: q, exact: true }); + } + } + } + } + Ok(best) + })?; + // The exact minimum lies within the chords of the mesh's; an answer outside that went astray. + let chords = a.chord + b.chord + 1e-9 * a.body.size().max(b.body.size()).max(1.0); + Ok(Some(match best { + Some(e) if e.distance <= m.distance + chords && e.distance >= m.distance - chords => e, + _ => mesh, + })) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b2be9b2f..69c79cdf 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -34,6 +34,7 @@ mod coplanar; mod corner_g2; mod curveblend; mod delete_face; +mod distance; #[cfg(test)] mod edge_polyline_tests; mod emboss; @@ -149,6 +150,7 @@ pub use continuity::{ }; pub use corner_g2::{corner_g2_patch, seam_error}; pub use delete_face::delete_faces; +pub use distance::{BodyDistance, DistanceShape, body_distance}; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use fair::{ FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve, diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs index 2982fb59..5f5cb47f 100644 --- a/crates/kernel/src/surface_kind.rs +++ b/crates/kernel/src/surface_kind.rs @@ -43,8 +43,12 @@ pub enum FaceSurface { /// The surface of face `face` (in face order), if it is one of the analytic kinds. pub fn face_surface(b: &Body, face: usize) -> Option { - let f = b.solid.face_iter().nth(face)?; - let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + surface_of(b.solid.face_iter().nth(face)?, b.size()) +} + +/// The analytic surface of face `f` of a body whose box diagonal is `size`. +pub(crate) fn surface_of(f: &mt::Face, size: f64) -> Option { + let tol = (size * 1e-7).max(1e-9) * 100.0; Some(match surf_of(f, tol)? { Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d }, Surf::Cylinder { o, a, r, .. } => FaceSurface::Cylinder { origin: o, axis: a, radius: r }, diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 90c7659f..75136aec 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -78,6 +78,8 @@ pub enum Asm { result: Option, overlaps: Vec, of: Vec, + /// Pairs closer than this (mm, as typed) are listed too. + clearance: String, }, /// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline. Trace(Box), @@ -189,9 +191,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { } "joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?, "joint.cam" => crate::dialogs_coupling::cam::start(app), - "inspect.interference" => { - (Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) - } + "inspect.interference" => ( + Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() }, + vec![SelInput::new("Bodies (all if none)", BODIES, true)], + ), _ => return None, }; Some((Kind::Assembly(asm), inputs)) @@ -691,12 +694,15 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se } Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs), Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs), - Asm::Interference { result, overlaps, of } => { + Asm::Interference { result, overlaps, of, clearance } => { let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); + row_label(ui, "Clearance"); + field(ui, clearance); + ui.end_row(); row_label(ui, ""); let stale = result.is_some() && *of != picked; if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() { - let (r, o) = interference(app, &picked); + let (r, o) = interference(app, &picked, clearance); *result = Some(r); *overlaps = o; *of = picked; @@ -716,16 +722,26 @@ fn capital(s: &str) -> String { c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } -/// Run the check and find the overlapping volumes (world placement) to show in red. -fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec) { +/// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and +/// find the overlapping volumes (world placement) to show in red. +fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value, Vec) { let names: Vec = picked.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); - let r = match app.run("inspect.interference", json!({ "bodies": names })) { + let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units); + let clearance = match crate::prefs::parse_length_mm(clearance, scale) { + Some(v) if v >= 0.0 => v, + _ => return (json!({ "error": "the clearance must be a length of 0 or more" }), Vec::new()), + }; + let mut p = json!({ "clearance": clearance, "nearest": 1 }); + if !names.is_empty() { + p["bodies"] = json!(names); + } + let r = match app.run("inspect.clearance", p) { Ok(v) => v, Err(e) => return (json!({ "error": e }), Vec::new()), }; let st = app.session.world_state(); let mut meshes = Vec::new(); - for hit in r["interferences"].as_array().into_iter().flatten() { + for hit in r["overlaps"].as_array().into_iter().flatten() { let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue }; if let Ok(Some(o)) = solidcraft_engine::kernel::boolean(&a.body, &b.body, solidcraft_engine::kernel::BoolOp::Intersect) && let Ok(m) = o.tessellate((o.size() * 2e-3).max(1e-3)) @@ -744,15 +760,43 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) { ui.end_row(); return; } - let hits = r["interferences"].as_array().cloned().unwrap_or_default(); + let hits = r["overlaps"].as_array().cloned().unwrap_or_default(); row_label(ui, "Checked"); - ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0))); + ui.label(format!("{} bodies", r["bodies"].as_u64().unwrap_or(0))); ui.end_row(); let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default(); + let near = r["near"].as_array().cloned().unwrap_or_default(); + let pair = |h: &Value| format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or("")); if hits.is_empty() && unchecked.is_empty() { row_label(ui, "Result"); ui.label(RichText::new(crate::i18n::tr_label("no interference")).color(t.text_dim)); ui.end_row(); + } + // Pairs closer than the clearance, nearest first. + if !near.is_empty() { + row_label(ui, "Too close"); + ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong()); + ui.end_row(); + } + for h in &near { + row_label(ui, &pair(h)); + ui.label(RichText::new(crate::prefs::show_mm(app, h["distance_mm"].as_f64().unwrap_or(0.0), 1)).color(t.warning)); + ui.end_row(); + } + // Each body's nearest neighbour. + let nearest = r["nearest"].as_object().cloned().unwrap_or_default(); + if !nearest.is_empty() { + row_label(ui, "Nearest"); + ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong()); + ui.end_row(); + } + for (body, list) in &nearest { + let Some(n) = list.get(0) else { continue }; + row_label(ui, &format!("{body} → {}", n["body"].as_str().unwrap_or(""))); + ui.label(crate::prefs::show_mm(app, n["distance_mm"].as_f64().unwrap_or(0.0), 1)); + ui.end_row(); + } + if hits.is_empty() && unchecked.is_empty() { return; } if !hits.is_empty() { diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 311545ff..6672bbf1 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -26,7 +26,25 @@ couples two joints. Links can be chained (one joint drives a second, which drive third), and a value is carried through a chain of up to 64 links in whatever order they were made. Contact sets stop motion where bodies would pass through each other, **Motion Study** keys joint values along a timeline, and **Interference** shows overlapping -volumes in red. +volumes in red, lists the pairs closer than its **Clearance** and each body's nearest neighbour. + +The check behind it, `inspect.clearance`, does all of that in one call for any number of bodies, +which suits scripts and agents checking a whole assembly: + +```json +{"command": "inspect.clearance", "params": {"clearance": 1, "nearest": 2, "skip_within": "component"}} +``` + +It returns `overlaps` (pairs sharing at least `min_volume`, 0.01 mm³ by default, with the shared +volume, area, box and centre), `near` (pairs closer than `clearance`, with the distance and the +two nearest points; a contact sharing less than `min_volume` is listed here at 0 with its +`overlap_mm3`), `nearest` (each body's `nearest` closest bodies) and `unchecked` (pairs whose +overlap could not be worked out, with the reason). `against` limits the check to pairs with one +body in `bodies` and the other in `against`, and `skip_within` leaves out pairs inside one +occurrence (`"occurrence"`) or one placed component, its subcomponents included +(`"component"`): the parts of a bought component that touch by design. Distances are measured on +the exact faces and edges; the meshes only rule out pairs that are too far apart, and the pairs +left are checked in parallel. `inspect.interference` remains for the overlaps alone. **Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose ratio comes from the parts rather than a typed number: From 172782705a343716cebb89d0dce8571407218090 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:30:13 +0200 Subject: [PATCH 119/149] fix: STEP import reads unclamped (periodic) B-splines on the seam (#1916) Thanks @NOWN-DEV. --- crates/kernel/src/step_in/geom.rs | 59 ++++++++++++++- crates/kernel/src/step_in/tests.rs | 117 +++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/crates/kernel/src/step_in/geom.rs b/crates/kernel/src/step_in/geom.rs index ef2d3a28..91dcb1a1 100644 --- a/crates/kernel/src/step_in/geom.rs +++ b/crates/kernel/src/step_in/geom.rs @@ -216,6 +216,57 @@ fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R { Ok(mt::KnotVec::from(v)) } +/// The ends of the part of a knot vector where a B-spline of `degree` is defined, when its end +/// knots repeat fewer than `degree + 1` times (an unclamped or periodic knot vector, as +/// OpenCascade writes closed B-splines and `UNIFORM_*` curves and surfaces have): only between +/// knot `degree` and knot `len - degree - 1` do the basis functions sum to one. `None` for a +/// clamped vector, whose whole range is the domain, and for one whose domain is a point. +fn unclamped_ends(kv: &mt::KnotVec, degree: usize) -> Option<(Option, Option)> { + let n = kv.len(); + if n < 2 * (degree + 1) || !(kv[n - 1 - degree] - kv[degree] > mt::TOLERANCE) { + return None; + } + let lo = (kv.multiplicity(0) <= degree).then(|| kv[degree]); + let hi = (kv.multiplicity(n - 1) <= degree).then(|| kv[n - 1 - degree]); + (lo.is_some() || hi.is_some()).then_some((lo, hi)) +} + +/// `c` restricted to its domain, with clamped ends (exact: knots are inserted at the domain's +/// ends and the control points beyond them dropped). The kernel takes a B-spline's whole knot +/// range as its parameter range, and an unclamped curve evaluated outside its domain leaves +/// its control polygon (a rational one divides by zero at the outermost knots). +fn clamped_curve + mt::Tolerance>(mut c: mt::BSplineCurve

) -> mt::BSplineCurve

{ + let Some((lo, hi)) = unclamped_ends(c.knot_vec(), c.degree()) else { return c }; + if let Some(lo) = lo { + c = c.cut(lo); + } + if let Some(hi) = hi { + let _ = c.cut(hi); + } + c +} + +/// [`clamped_curve`] for a surface, in each direction whose knot vector is unclamped. +fn clamped_surface + mt::Tolerance>(mut s: mt::BSplineSurface

) -> mt::BSplineSurface

{ + if let Some((lo, hi)) = unclamped_ends(s.uknot_vec(), s.udegree()) { + if let Some(lo) = lo { + s = s.ucut(lo); + } + if let Some(hi) = hi { + let _ = s.ucut(hi); + } + } + if let Some((lo, hi)) = unclamped_ends(s.vknot_vec(), s.vdegree()) { + if let Some(lo) = lo { + s = s.vcut(lo); + } + if let Some(hi) = hi { + let _ = s.vcut(hi); + } + } + s +} + fn weights_list(p: Option<&Param>) -> R> { p.and_then(Param::as_list) .ok_or("rational B-spline without weights")? @@ -267,9 +318,9 @@ fn bspline_curve(cx: &Ctx, e: &Entity) -> R { Some(r) => { let w = weights_list(r.first())?; let n = mt::NurbsCurve::::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational B-spline: {e}"))?; - Ok(CurveGeo::Nurbs(n)) + Ok(CurveGeo::Nurbs(mt::NurbsCurve::new(clamped_curve(n.into_non_rationalized())))) } - None => Ok(CurveGeo::BSpline(bsp)), + None => Ok(CurveGeo::BSpline(clamped_curve(bsp))), } } @@ -889,9 +940,9 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R { return Err("weights do not match the control net".into()); } let n = mt::NurbsSurface::::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational surface: {e}"))?; - Ok(mt::Surface::NurbsSurface(n)) + Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(clamped_surface(n.into_non_rationalized())))) } - None => Ok(mt::Surface::BSplineSurface(bsp)), + None => Ok(mt::Surface::BSplineSurface(clamped_surface(bsp))), } } diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 6dd7f4ea..8a195af6 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -1255,3 +1255,120 @@ fn revolved_faces_skewed_never_panic_on_export() { } } } + +/// Weights of a rational quadratic full circle through nine control points. +const CIRCLE_WEIGHTS: &str = "1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1."; +/// Angle knots as OpenCascade writes a closed (periodic) quadratic circle: the end knots +/// appear once, so the B-spline is defined from knot 2 to knot 9 only, -π to π. +const PERIODIC_MULTS: &str = "1,2,2,2,2,2,1"; +const PERIODIC_KNOTS: &str = "-4.712388980385,-3.14159265359,-1.570796326795,0.,1.570796326795,3.14159265359,4.712388980385"; + +/// Nine control points of a quadratic circle of radius `r` about (`x`, 0, `z`), from angle 0. +fn circle_net(first: u64, x: f64, r: f64, z: f64) -> (String, Vec) { + let (mut s, mut ids) = (String::new(), Vec::new()); + for i in 0..9u64 { + let a = i as f64 * std::f64::consts::FRAC_PI_4; + let q = if i % 2 == 1 { r * std::f64::consts::SQRT_2 } else { r }; + s += &format!("#{}=CARTESIAN_POINT('',({:?},{:?},{z:?}));\n", first + i, x + q * a.cos(), q * a.sin()); + ids.push(format!("#{}", first + i)); + } + (s, ids) +} + +/// A tube face of radius `r` and length `h` about the Z axis through (`x`, 0, 0), written the +/// way OpenCascade writes one: a rational degree (1, 2) B-spline surface with the periodic +/// angle knots above, bounded by its two rims and a seam. Entities from `first` on; returns +/// them and the face's id. +fn periodic_knot_tube(first: u64, x: f64, r: f64, h: f64) -> (String, u64) { + let id = |k: u64| first + k; + let (net0, row0) = circle_net(id(40), x, r, 0.0); + let (net1, row1) = circle_net(id(50), x, r, h); + let (p0, p1, v0, v1) = (id(0), id(1), id(2), id(3)); + let (c0, c1, up, down, ref_dir) = (id(4), id(5), id(6), id(7), id(8)); + let (a0, a1, rim0, rim1) = (id(9), id(10), id(11), id(12)); + let (e0, e1, along, seam_line, seam) = (id(13), id(14), id(15), id(16), id(17)); + let (o0, o1, o2, o3, lp, bound, surface, face) = (id(18), id(19), id(20), id(21), id(22), id(23), id(24), id(25)); + let mut s = net0 + &net1; + let xr = x + r; + s += &format!("#{p0}=CARTESIAN_POINT('',({xr:?},0.,0.));\n#{p1}=CARTESIAN_POINT('',({xr:?},0.,{h:?}));\n"); + s += &format!("#{v0}=VERTEX_POINT('',#{p0});\n#{v1}=VERTEX_POINT('',#{p1});\n"); + s += &format!("#{c0}=CARTESIAN_POINT('',({x:?},0.,0.));\n#{c1}=CARTESIAN_POINT('',({x:?},0.,{h:?}));\n"); + s += &format!("#{up}=DIRECTION('',(0.,0.,1.));\n#{down}=DIRECTION('',(0.,0.,-1.));\n#{ref_dir}=DIRECTION('',(1.,0.,0.));\n"); + s += &format!("#{a0}=AXIS2_PLACEMENT_3D('',#{c0},#{up},#{ref_dir});\n#{a1}=AXIS2_PLACEMENT_3D('',#{c1},#{down},#{ref_dir});\n"); + s += &format!("#{rim0}=CIRCLE('',#{a0},{r:?});\n#{rim1}=CIRCLE('',#{a1},{r:?});\n"); + s += &format!("#{e0}=EDGE_CURVE('',#{v0},#{v0},#{rim0},.T.);\n#{e1}=EDGE_CURVE('',#{v1},#{v1},#{rim1},.T.);\n"); + s += &format!("#{along}=VECTOR('',#{up},1.);\n#{seam_line}=LINE('',#{p0},#{along});\n#{seam}=EDGE_CURVE('',#{v0},#{v1},#{seam_line},.T.);\n"); + s += &format!( + "#{o0}=ORIENTED_EDGE('',*,*,#{e0},.F.);\n#{o1}=ORIENTED_EDGE('',*,*,#{seam},.T.);\n#{o2}=ORIENTED_EDGE('',*,*,#{e1},.F.);\n#{o3}=ORIENTED_EDGE('',*,*,#{seam},.F.);\n" + ); + s += &format!("#{lp}=EDGE_LOOP('',(#{o0},#{o1},#{o2},#{o3}));\n#{bound}=FACE_BOUND('',#{lp},.T.);\n"); + s += &format!( + "#{surface}=(BOUNDED_SURFACE()B_SPLINE_SURFACE(1,2,(({}),({})),.UNSPECIFIED.,.F.,.T.,.F.)B_SPLINE_SURFACE_WITH_KNOTS((2,2),({PERIODIC_MULTS}),(0.,{:?}),({PERIODIC_KNOTS}),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE((({CIRCLE_WEIGHTS}),({CIRCLE_WEIGHTS})))REPRESENTATION_ITEM('')SURFACE());\n", + row0.join(","), + row1.join(","), + h / r + ); + s += &format!("#{face}=ADVANCED_FACE('',(#{bound}),#{surface},.T.);\n"); + (s, face) +} + +/// A B-spline whose end knots repeat fewer than degree + 1 times (an unclamped knot vector, +/// as OpenCascade writes closed curves and surfaces) is defined only between knot `degree` +/// and knot `len - degree - 1`. Read over the whole knot range, the circle and the tube went +/// on past their domain, and the rational ones divided by zero at the outermost knots. +#[test] +fn periodic_knot_bsplines_are_read_on_their_domain() { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface}; + let (r, h) = (1.7, 1.5); + let (tube, face) = periodic_knot_tube(100, 0.0, r, h); + let (circle, row) = circle_net(10, 0.0, r, 0.0); + let text = format!( + "{HEAD}{tube}{circle}#30=(BOUNDED_CURVE()B_SPLINE_CURVE(2,({}),.CIRCULAR_ARC.,.T.,.F.)B_SPLINE_CURVE_WITH_KNOTS(({PERIODIC_MULTS}),({PERIODIC_KNOTS}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({CIRCLE_WEIGHTS}))REPRESENTATION_ITEM(''));\n{TAIL}", + row.join(",") + ); + let ex = p21::parse(&text).unwrap(); + let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 }; + let pi = std::f64::consts::PI; + let on_tube = |p: mt::Point3| (p.x.hypot(p.y) - r).abs() < 1e-9 && p.z > -1e-9 && p.z < h + 1e-9; + + let surface_id = ex.get(face).and_then(|e| e.params().get(2)).and_then(Param::as_ref_id).unwrap(); + let surface = geom::bspline_surface(&cx, ex.get(surface_id).unwrap()).unwrap(); + let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { panic!("unbounded surface") }; + assert!(u0.abs() < 1e-12 && (u1 - h / r).abs() < 1e-12, "u range {u0} .. {u1}"); + assert!((v0 + pi).abs() < 1e-9 && (v1 - pi).abs() < 1e-9, "v range {v0} .. {v1}"); + for i in 0..=8 { + for j in 0..=16 { + let (u, v) = (u0 + (u1 - u0) * i as f64 / 8.0, v0 + (v1 - v0) * j as f64 / 16.0); + assert!(on_tube(surface.subs(u, v)), "S({u}, {v}) = {:?}", surface.subs(u, v)); + } + } + + let geom::CurveGeo::Nurbs(curve) = geom::curve(&cx, 30, 0).unwrap() else { panic!("not a rational B-spline") }; + let (t0, t1) = curve.range_tuple(); + assert!((t0 + pi).abs() < 1e-9 && (t1 - pi).abs() < 1e-9, "curve range {t0} .. {t1}"); + for j in 0..=16 { + let t = t0 + (t1 - t0) * j as f64 / 16.0; + assert!(on_tube(curve.subs(t)), "C({t}) = {:?}", curve.subs(t)); + } +} + +/// Two such tubes far apart in one body: the body's size sets a chord tolerance coarse beside +/// the tubes, and meshing a face at it divided the surface's whole parameter box, whose +/// corners lay outside the domain. The division never converged there (#1374's recursion), +/// and the import grew by gigabytes without end. +#[test] +fn periodic_knot_tube_faces_import_and_mesh() { + let t0 = std::time::Instant::now(); + let (r, h) = (1.7, 1.5); + let (a, fa) = periodic_knot_tube(100, 0.0, r, h); + let (b, fb) = periodic_knot_tube(200, 700.0, r, h); + let text = format!( + "{HEAD}{a}{b}#1=OPEN_SHELL('',(#{fa},#{fb}));\n#2=SHELL_BASED_SURFACE_MODEL('Tubes',(#1));\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}" + ); + let imp = step_import(&text).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + let m = measure(&imp.bodies[0].body).unwrap(); + let area = 2.0 * std::f64::consts::TAU * r * h; + assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area); + assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed()); +} From 39758b0cc4da55cf49d95ead408860f4ad7aa00c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:31:55 +0000 Subject: [PATCH 120/149] Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] --- docs/features.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/features.md b/docs/features.md index fb5fb87b..947b5914 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **389 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **390 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (103) @@ -309,7 +309,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Add Recorded Feature | `timeline.add` | – | | Export Command Script | `timeline.export_script` | – | -## Inspect (14) +## Inspect (15) | Command | Id | Toolbar | |---|---|---| @@ -324,6 +324,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Curvature Map Analysis | `inspect.curvature_map` | SKETCH › INSPECT | | Continuity Analysis | `inspect.continuity` | SURFACE › INSPECT | | Interference | `inspect.interference` | SOLID › INSPECT | +| Clearance | `inspect.clearance` | – | | Measure (I) | `inspect.measure` | SOLID › INSPECT | | Compare Bodies | `inspect.compare` | – | | Section Analysis | `inspect.section` | SOLID › INSPECT | From 3fe6bacea092977bc661af41387685757067e53c Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:37:16 +0200 Subject: [PATCH 121/149] fix: a blind cut on a long body keeps its small rounds (#1593) Thanks @NOWN-DEV. --- crates/engine/src/cmd/sheet_tests.rs | 58 ++++++++++++++ crates/kernel/src/polybool.rs | 113 ++++++++++++++++++++++----- 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 96f53ba0..0e30dcaa 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2816,6 +2816,64 @@ fn a_pocket_around_a_cut_out_rebuilds_on_a_folded_sheet() { } } +/// Engraving a long channel leaves its corner rounds round. The channel (1200 long, 1 thick) has +/// 2 mm rounds where its legs end, and two 5.95 x 5 pockets 0.1 apart are cut 0.1 deep into a leg +/// halfway along. Their walls in one plane leave the second pocket to the exact planar fallback, +/// whose mesh (a thousandth of the body, 1.2 mm) is flat across a 2 mm round: taken for a plane, +/// the rounds came back flat chamfers beside a round hole, and without one the cut failed. +#[test] +fn engraving_a_long_channel_keeps_its_corner_rounds() { + for hole in [false, true] { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Thin", "active": true, "thickness": "1 mm", "bend_radius": "1 mm", "k_factor": "0.44"})); + // Along z, 20 wide, its legs 65 and 25 high from the web at y = 65. + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, -600], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Profile"})); + run(&mut s, "sketch.line", json!({"points": [[-10, 0], [-10, 65], [10, 65], [10, 40]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Profile", "curves": ["l1", "l2", "l3"], "distance": 1200})); + // Each leg's free edge rounded at both ends: the corner outside a quarter circle, cut through. + let legs = [(-10.0_f64, 0.0_f64), (10.0, 40.0)]; + for (k, (x, y)) in legs.iter().enumerate() { + let name = format!("Rounds{k}"); + run(&mut s, "sketch.create", json!({"plane": {"origin": [x, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": name})); + for end in [-600.0, 600.0_f64] { + let c = end - 2.0 * end.signum(); + let far = end + 2.0 * end.signum(); + run(&mut s, "sketch.line", json!({"points": [[c, y], [c, y - 2.0], [far, y - 2.0], [far, y + 2.0], [end, y + 2.0]]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [c, y + 2.0], "start": [end, y + 2.0], "sweep": -90.0 * end.signum()})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + if hole { + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [360, 57], "diameter": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + let v0 = volume(&mut s); + // Into the outer face of the leg at x = -10 (its material from -10 to -9). + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Label"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-6, 30], "p1": [-0.05, 35]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.05, 30], "p1": [6, 35]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Label", "distance": 0.1, "direction": "negative", "operation": "cut"})); + // 2 x 5.95 x 5 x 0.1. + let removed = v0 - volume(&mut s); + assert!((removed - 5.95).abs() < 1e-3, "hole {hole}: removed {removed}"); + // A point on each round, halfway through the leg: 2 - 2 cos 45 deg in from both edges. + let d = 2.0 - std::f64::consts::SQRT_2; + let faces: Vec<[f64; 3]> = + legs.iter().flat_map(|(x, y)| [-600.0, 600.0_f64].map(|end| [x + 0.5 * -x.signum(), y + d, end - d * end.signum()])).collect(); + let radii = run(&mut s, "inspect.minimum_radius", json!({"faces": faces})); + let items = radii["items"].as_array().unwrap(); + assert_eq!(items.len(), 4); + for item in items { + assert!(item["min_radius"].as_f64().is_some_and(|r| (r - 2.0).abs() < 1e-6), "hole {hole}: {item}"); + } + } +} + /// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working: /// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263). #[test] diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 7d03a82d..951c3216 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -227,13 +227,36 @@ impl NotPlanar { } } +/// The kind of curved surface `face` lies on, or None when it lies on a plane. `flat`: whether +/// its mesh is flat. The meshes here are as coarse as a thousandth of the body, which spans a +/// round much smaller than that (a 2 mm corner of a 1.2 m channel) with one flat strip of +/// facets: the surface decides, and the mesh only for a surface none of these kinds fits. +fn curved_kind(face: &mt::Face, flat: bool, tol: f64) -> Option<&'static str> { + use crate::offset::{Surf, surf_of}; + if matches!(face.surface(), mt::Surface::Plane(_)) { + return None; + } + match surf_of(face, tol) { + Some(Surf::Plane { .. }) => None, + Some(Surf::Cylinder { .. }) => Some("cylinder"), + Some(Surf::Cone { .. }) => Some("cone"), + Some(Surf::Sphere { .. }) => Some("sphere"), + Some(Surf::Torus { .. }) => Some("torus"), + None => (!flat).then_some("freeform"), + } +} + +/// The tolerance [`curved_kind`] tells surfaces apart by: face_surface's, with the body's +/// bounds computed once. Walking to each indexed face separately made curved-body diagnostics +/// quadratic. +fn kind_tol(b: &Body) -> f64 { + (b.size() * 1e-7).max(1e-9) * 100.0 +} + /// The plane of each face (from its mesh, which must be flat), or what stands in the way: the /// curved faces by the kind of surface they lie on. fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result, NotPlanar> { - use crate::offset::{Surf, surf_of}; - // Match face_surface's classification tolerance, but compute the body bounds only - // once. Walking to each indexed face separately made curved-body diagnostics quadratic. - let surface_tol = (b.size() * 1e-7).max(1e-9) * 100.0; + let surface_tol = kind_tol(b); let mut surfaces = b.solid.face_iter(); let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new(); let mut out = Vec::with_capacity(faces.len()); @@ -247,19 +270,10 @@ fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result out.push(PlaneEq { n, w: n.dot(f.centroid) }), - None => { - let kind = match surf_of(surface, surface_tol) { - Some(Surf::Cylinder { .. }) => "cylinder", - Some(Surf::Cone { .. }) => "cone", - Some(Surf::Sphere { .. }) => "sphere", - Some(Surf::Torus { .. }) => "torus", - Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), - None => "freeform", - }; - *curved.entry(kind).or_insert(0) += 1; - } + match (f.plane_normal, curved_kind(surface, f.plane_normal.is_some(), surface_tol)) { + (Some(n), None) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), + (None, None) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), + (_, Some(kind)) => *curved.entry(kind).or_insert(0) += 1, } } if surfaces.next().is_some() { @@ -437,9 +451,14 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option> = + a.solid.face_iter().zip(&infos).map(|(f, info)| info.plane_normal.filter(|_| curved_kind(f, true, surface_tol).is_none())).collect(); let mut curved: BTreeMap = BTreeMap::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { - if infos.get(*fi as usize)?.plane_normal.is_some() { + if planes.get(*fi as usize)?.is_some() { continue; } let bx = curved.entry(*fi).or_insert(solidcraft_geom::Aabb3::EMPTY); @@ -458,7 +477,7 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect(); + let flat: Vec<(Vec3, Vec3)> = planes.iter().zip(&infos).filter_map(|(n, f)| Some(((*n)?, f.centroid))).collect(); let gap = size * 1e-4; let place = |k: usize, near: f64, far: f64| -> Option { let axis = *axes.get(k)?; @@ -1223,3 +1242,59 @@ mod diagnostic_tests { assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into())); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod small_round_tests { + use std::f64::consts::FRAC_PI_2; + + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + + use super::*; + + /// An `l` x `w` x 1 plate on XY with its four corners rounded (radius `r`), and a hole of + /// diameter 4 through it 100 from one end when `hole`. + fn rounded_plate(l: f64, w: f64, r: f64, hole: bool) -> Body { + let (x, y) = (l / 2.0, w / 2.0); + let corners = [(x - r, y - r), (r - x, y - r), (r - x, r - y), (x - r, r - y)]; + let mut segs = Vec::new(); + for (k, (cx, cy)) in corners.iter().enumerate() { + let start = k as f64 * FRAC_PI_2; + let at = |t: f64| Vec2::new(cx + r * t.cos(), cy + r * t.sin()); + segs.push(Seg2::Arc { center: Vec2::new(*cx, *cy), radius: r, start, sweep: FRAC_PI_2 }); + let (nx, ny) = corners[(k + 1) % 4]; + let next = start + FRAC_PI_2; + segs.push(Seg2::Line { a: at(next), b: Vec2::new(nx + r * next.cos(), ny + r * next.sin()) }); + } + let holes = if hole { vec![Loop2::circle(Vec2::new(x - 100.0, 0.0), 2.0).reversed()] } else { Vec::new() }; + crate::extrude_planar_regions(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes }], 0.0, 1.0).unwrap() + } + + /// Faces of `b` on cylinders of radius `r`. + fn cylinders(b: &Body, r: f64) -> usize { + (0..b.face_count()) + .filter(|&i| matches!(crate::face_surface(b, i), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-9)) + .count() + } + + /// A pocket cut into the middle of a long plate by the exact fallback leaves the plate's + /// small corner rounds round. Meshed at a thousandth of the 1200 mm plate (1.2 mm), each + /// 2 mm quarter round (its chord 0.59 mm inside the arc) is one flat strip of facets, which + /// was taken for a plane: the whole plate went to the polygon boolean (and, beside a round + /// hole, the rounds went into the planar slab around the pocket), and the rounds came back + /// flat chamfers, each taking r^2 (pi/4 - 1/2) = 1.14 mm^3 more than its round. + #[test] + fn a_long_plates_small_rounds_stay_round_beside_a_pocket() { + for hole in [false, true] { + let plate = rounded_plate(1200.0, 40.0, 2.0, hole); + assert_eq!(cylinders(&plate, 2.0), 4 + 2 * usize::from(hole)); + // 10 x 5, 0.1 deep from the top. + let tool = crate::box_solid(Vec3::new(-5.0, -2.5, 0.9), Vec3::new(5.0, 2.5, 1.0)).unwrap(); + let r = planar_boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap(); + assert_eq!(cylinders(&r, 2.0), 4 + 2 * usize::from(hole), "hole {hole}"); + // The pocket's 10 x 5 x 0.1 and nothing else (a flattened round takes 1.14 more). + let removed = crate::measure(&plate).unwrap().volume - crate::measure(&r).unwrap().volume; + assert!((removed - 5.0).abs() < 1e-3, "hole {hole}: removed {removed}"); + } + } +} From 44732f8b40fc3132c27b92526db83cd859e9000b Mon Sep 17 00:00:00 2001 From: HiroKenEz <59478073+HiroKenEz@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:37:21 +0200 Subject: [PATCH 122/149] Parameters: a favourite's expression takes each key once (#1767) (#1919) Thanks @HiroKenEz. --- crates/ui-egui/src/params_dialog.rs | 34 +++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 23bdb450..d50af95c 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -280,7 +280,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if r.source == "user" { let mut sel = UNITS.iter().position(|u| *u == r.unit).unwrap_or(0); let before = sel; - egui::ComboBox::from_id_salt(("punit", &r.name)).width(56.0).selected_text(unit_label(&r.unit)).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt(("punit", salt, &r.name)).width(56.0).selected_text(unit_label(&r.unit)).show_ui(ui, |ui| { for (i, u) in UNITS.iter().enumerate() { ui.selectable_value(&mut sel, i, unit_label(u)); } @@ -301,10 +301,15 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if bad { te = te.text_color(t.error); } - let id = ui.make_persistent_id(("pexpr", &r.name)); + // Per table: a favourite is listed twice, and one id would give both boxes the focus and + // each key to both (#1767). + let id = ui.make_persistent_id(("pexpr", salt, &r.name)); let focused = ui.memory(|m| m.has_focus(id)); let resp = ui.add_sized([170.0, 20.0], te.id(id)); crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center()); + if salt == "fav" { + crate::scenario::publish_handle(&format!("param:favorite:{}", r.name), resp.rect.center()); + } let completed = complete(ui, &resp, &mut e); if resp.changed() || completed { d.editing.insert(r.name.clone(), e.clone()); @@ -548,4 +553,29 @@ mod tests { assert!(check(&mut app, "width / 2", "mm").is_ok()); assert!(check(&mut app, "width + 1 deg", "mm").is_err()); } + + /// 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] + fn a_favourite_parameter_takes_each_key_once() { + for (favourite, row) in [(false, 0), (true, 0), (true, 1)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name": "width", "expression": "10 mm"})).unwrap(); + if favourite { + h.app.run("parameters.favorite", json!({"name": "width", "favorite": true})).unwrap(); + } + h.step(&json!({"start": "parameters.change"})).unwrap(); + h.frames(4); + // `param:width` is the box in the parameter's own table, `param:favorite:width` the one + // under Favorites. + h.step(&json!({"click": {"handle": if row == 0 { "param:width" } else { "param:favorite:width" }}})).unwrap(); + h.frames(2); + h.step(&json!({"key": "A", "cmd": true})).unwrap(); + h.step(&json!({"text": "1.1"})).unwrap(); + h.frames(2); + let editing = h.app.params.as_ref().unwrap().editing.get("width").cloned(); + assert_eq!(editing.as_deref(), Some("1.1"), "favourite {favourite}, row {row}"); + } + } } From 8c58efdecf0244798a3178fc96ac53522cf2ac26 Mon Sep 17 00:00:00 2001 From: allisonChilton Date: Sat, 17 Oct 2026 04:10:46 -0600 Subject: [PATCH 123/149] Fix missing viewport surfaces after a skipped startup frame (#1791) Thanks @allisonChilton. --- ATTRIBUTION.md | 2 + vendor/README.md | 2 + vendor/egui-wgpu/src/winit.rs | 78 ++++++++++++++++++----------------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cab087e9..8760ef1a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -466,3 +466,5 @@ and shares that effective count with the surface attachments and CAD callbacks. Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling. A frame whose meshes the GPU refuses to stage (device lost or out of memory) is skipped with a warning instead of panicking (#1469). +Paint callbacks are prepared only after a drawable is acquired, so a skipped +surface frame cannot strand cached meshes without their GPU initialization. diff --git a/vendor/README.md b/vendor/README.md index 36bc8f17..d043cbd5 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -210,3 +210,5 @@ SolidCraft changes: clamped to it (`clamp_surface_extent`, aspect ratio kept) and egui is drawn at the matching smaller scale, instead of a `Surface::configure` validation panic (#869). The resize path and an unsupported surface no longer unwrap. +- `src/winit.rs`: acquire a drawable before preparing paint callbacks, so a skipped + surface frame cannot discard initialization commands for cached viewport meshes. diff --git a/vendor/egui-wgpu/src/winit.rs b/vendor/egui-wgpu/src/winit.rs index a2c00fe4..832f64f2 100644 --- a/vendor/egui-wgpu/src/winit.rs +++ b/vendor/egui-wgpu/src/winit.rs @@ -588,43 +588,6 @@ impl Painter { return vsync_sec; }; - let mut encoder = - render_state - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("encoder"), - }); - - // Upload all resources for the GPU. - // SolidCraft: a clamped surface draws the whole window at a smaller scale. - let screen_descriptor = renderer::ScreenDescriptor { - size_in_pixels: [surface_state.width, surface_state.height], - pixels_per_point: pixels_per_point * surface_state.scale, - }; - - let user_cmd_bufs = { - let mut renderer = render_state.renderer.write(); - #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here - for (id, image_deltas) in textures_delta.set.drain() { - for image_delta in image_deltas { - renderer.update_texture( - &render_state.device, - &render_state.queue, - id, - &image_delta, - ); - } - } - - renderer.update_buffers( - &render_state.device, - &render_state.queue, - &mut encoder, - clipped_primitives, - &screen_descriptor, - ) - }; - if surface_state.needs_reconfigure { Self::configure_surface(surface_state, render_state, &self.config.surface); surface_state.needs_reconfigure = false; @@ -670,6 +633,47 @@ impl Painter { } }; + // SolidCraft: acquire the drawable before preparing callbacks. Preparation can + // cache GPU resources whose initialization is recorded into `encoder` (such as + // the viewport's tessellated vertices). If acquisition skips this frame, dropping + // that encoder leaves the cached resources uninitialized on every later frame. + let mut encoder = + render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("encoder"), + }); + + // Upload all resources for the GPU. + // SolidCraft: a clamped surface draws the whole window at a smaller scale. + let screen_descriptor = renderer::ScreenDescriptor { + size_in_pixels: [surface_state.width, surface_state.height], + pixels_per_point: pixels_per_point * surface_state.scale, + }; + + let user_cmd_bufs = { + let mut renderer = render_state.renderer.write(); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + renderer.update_texture( + &render_state.device, + &render_state.queue, + id, + &image_delta, + ); + } + } + + renderer.update_buffers( + &render_state.device, + &render_state.queue, + &mut encoder, + clipped_primitives, + &screen_descriptor, + ) + }; + let mut capture_buffer = None; { let renderer = render_state.renderer.read(); From 29dfa6bd328036ffe8f0933b9a267aa35570218b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 17 Oct 2026 06:19:12 -0400 Subject: [PATCH 124/149] Attribution: the Czech catalog (#1952) --- ATTRIBUTION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8760ef1a..a9519830 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -80,6 +80,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | +| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | From 7d44359cf6d8271f9e90295f40a789ca5b11232d Mon Sep 17 00:00:00 2001 From: Steven Huff <62350898+shuff57@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:22:52 -0700 Subject: [PATCH 125/149] Web: show the SolidCraft mark being cut from a cube while loading (#1908) Thanks @shuff57. --- ATTRIBUTION.md | 1 + apps/solidcraft-web/Cargo.toml | 2 +- apps/solidcraft-web/index.html | 3 +- apps/solidcraft-web/src/main.rs | 27 +++++++++- assets/styles/web/loading-logo.svg | 87 ++++++++++++++++++++++++++++++ assets/styles/web/loading.css | 5 +- 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 assets/styles/web/loading-logo.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a9519830..2d66adb1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -103,6 +103,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 | | assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | +| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 | | assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 | | crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 | diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index 4c615fb4..668fe086 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", - "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", + "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance", ] } diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html index 2157e05e..c1068d5b 100644 --- a/apps/solidcraft-web/index.html +++ b/apps/solidcraft-web/index.html @@ -23,6 +23,7 @@ + @@ -35,7 +36,7 @@ -

Loading SolidCraft…
+
Loading SolidCraft…