From 462de5b9d2df088261c53ef0a109a0d36093f5b2 Mon Sep 17 00:00:00 2001 From: Dahak Builds Date: Sun, 1 Nov 2026 12:46:17 +0000 Subject: [PATCH 1/6] Fix extrude preview placement in placed components (#2103) (#10596) * claim: #2103 * Pin #2103: extrude-cut preview placement on placed component occurrences * Format #2103 regression tests * Satisfy clippy lints in #2103 regression tests --------- Co-authored-by: Dahak Builds --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/preview_placement_tests.rs | 121 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 crates/ui-egui/src/preview_placement_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5be45b590c..8dfdf51c05 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -206,6 +206,8 @@ mod prefs_search; #[cfg(test)] mod preselect_tests; pub mod preview; +#[cfg(test)] +mod preview_placement_tests; mod primitive_placement; #[cfg(test)] mod primitive_units_tests; diff --git a/crates/ui-egui/src/preview_placement_tests.rs b/crates/ui-egui/src/preview_placement_tests.rs new file mode 100644 index 0000000000..6d0cc98b86 --- /dev/null +++ b/crates/ui-egui/src/preview_placement_tests.rs @@ -0,0 +1,121 @@ +//! #2103: the live preview of an Extrude (Cut) on a sketch attached to a face of a body inside +//! a placed component occurrence must be drawn where the viewport shows the sketch, not at the +//! component's own frame. The committed result was always correct; the preview used to be built +//! from the component-local model state (`Session::preview`) while the viewport shows placed +//! occurrences, so the tool appeared offset by the placement — seen on a mirrored STEP copy, +//! and on a plain translated copy of a component. The fix routes the tool preview through +//! `Session::preview_world` (shipped in #4455); these tests pin the placement of the cut tool +//! the preview draws. + +use serde_json::{Value, json}; +use solidcraft_engine::Session; +use solidcraft_engine::doc::{FeatureKind, PlaneRef}; +use solidcraft_engine::geom::Vec3; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}")) +} + +/// The ear component: a 40 × 20 × 10 block `EarBlock` (local 0..40 x, 0..20 y, 0..10 z), +/// optionally with its body mirrored in the component's own YZ plane (the mirrored body of the +/// STEP copy — a document keeps every occurrence placement rigid, so an import's mirror lives +/// in the geometry), then its occurrence moved by [10, 20, 0] and left uncaptured: the +/// occurrence transform is what the viewport draws. +fn ear(mirror: bool) -> (Session, u64, u64) { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name": "Ear", "grounded": false})); + let (component, occurrence) = (created["component"].as_u64().unwrap(), created["occurrence"].as_u64().unwrap()); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10, "body_name": "EarBlock"})); + if mirror { + run(&mut s, "solid.mirror", json!({"bodies": ["EarBlock"], "plane": "YZ"})); + } + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [10, 20, 0]})); + (s, component, occurrence) +} + +/// A face-attached sketch on the placed block's top face at `face_point` (world), with two +/// circular cut profiles 8 mm either side of the picked point along the face plane's x axis, +/// and the Cut command the dialog would apply: One Side, 5 mm into the material. +/// Returns the command and the sketch. +fn two_circle_cut_on_face(s: &mut Session, component: u64, occurrence: u64, face_point: [f64; 3]) -> (Vec<(String, Value)>, u64) { + run(s, "component.activate", json!({"component": component, "occurrence": occurrence})); + let sketch = run(s, "sketch.create", json!({"plane": {"face": face_point}, "project_edges": false}))["sketch"].as_u64().unwrap(); + // The face plane's origin is the component's own, not the pick: place the circles around + // the picked point in the plane's coordinates so both profiles sit on the face. + let FeatureKind::Sketch { plane: PlaneRef::Face { plane, at, .. }, .. } = &s.doc.feature(sketch).unwrap().kind else { + panic!("a sketch on a face") + }; + let centre = plane.to_local(*at); + for d in [-8.0, 8.0] { + run(s, "sketch.circle.center", json!({"center": [centre.x + d, centre.y], "radius": 3})); + } + run(s, "sketch.finish", json!({})); + let commands = + vec![("solid.extrude".to_string(), json!({"sketch": sketch, "profiles": "all", "operation": "cut", "distance": 5, "direction": "negative"}))]; + (commands, sketch) +} + +/// The vertices the cut preview draws as the see-through red tool (`GpuScene::xray`: 28-byte +/// records of position, normal and palette index), as their world bounds. +fn tool_bounds(xray: &[u8]) -> (Vec3, Vec3) { + assert!(!xray.is_empty() && xray.len().is_multiple_of(28), "the cut preview must draw its tool"); + let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]); + for record in xray.as_chunks::<28>().0 { + for k in 0..3 { + let v = f32::from_le_bytes(record[k * 4..k * 4 + 4].try_into().unwrap()) as f64; + lo[k] = lo[k].min(v); + hi[k] = hi[k].max(v); + } + } + (Vec3::new(lo[0], lo[1], lo[2]), Vec3::new(hi[0], hi[1], hi[2])) +} + +/// The tool must sit at `want` (world): its bounds centre within a tenth of a millimetre, its +/// spans within half a millimetre (an inscribed tessellation), and far from `buggy` — where a +/// preview built from the component-local state would draw it (#2103). +fn assert_tool_at(built: &crate::preview::Built, want: [f64; 3], buggy: [f64; 3]) { + assert_eq!(built.error, None, "the cut preview must not fail"); + let (lo, hi) = tool_bounds(&built.scene.xray); + let mid = [(lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0, (lo.z + hi.z) / 2.0]; + let span = [hi.x - lo.x, hi.y - lo.y, hi.z - lo.z]; + for k in 0..3 { + assert!((mid[k] - want[k]).abs() < 0.1, "tool centre {mid:?}, want {want:?} (the local-frame bug drew it centred near {buggy:?})"); + } + let off = ((mid[0] - buggy[0]).powi(2) + (mid[1] - buggy[1]).powi(2) + (mid[2] - buggy[2]).powi(2)).sqrt(); + assert!(off > 15.0, "the tool sits at the component-local place the bug drew it ({buggy:?}, off by {off})"); + let want_span = [22.0, 6.0, 5.0]; + for k in 0..3 { + assert!((span[k] - want_span[k]).abs() < 0.5, "tool span {span:?}, want {want_span:?}"); + } +} + +/// #2103: a copy of a component translated by [10, 20, 0], a sketch on its top face with two +/// circular cut profiles: the red Cut preview sits on the sketch where the viewport shows it +/// (centred at the placed face, spanning both circles, 5 mm down into the material), not at the +/// component's own frame offset by the placement. +#[test] +fn extrude_cut_preview_on_a_face_sketch_in_a_translated_component_sits_at_the_sketch() { + let (mut s, component, occurrence) = ear(false); + let (commands, sketch) = two_circle_cut_on_face(&mut s, component, occurrence, [30.0, 30.0, 10.0]); + let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::dark()); + let built = crate::preview::compute(&s, &commands, colors).unwrap(); + // Placed: the local top-face centre (20, 10, 10) plus [10, 20, 0]; the tool cuts 5 mm down. + assert_tool_at(&built, [30.0, 30.0, 7.5], [20.0, 10.0, 7.5]); + // The sketch is anchored to the face of the placed body, as in the issue. + let FeatureKind::Sketch { plane: PlaneRef::Face { .. }, .. } = s.doc.feature(sketch).unwrap().kind else { panic!("a sketch on a face") }; +} + +/// #2103: the shape of the STEP repro — a component whose body was mirrored, placed as a copy +/// (a document keeps occurrence placements rigid, so an import's mirror sits in the geometry). +/// The Cut preview must still sit on the sketch at its world position, not at the component's +/// own frame, and not be thrown off by the mirrored body beside it. +#[test] +fn extrude_cut_preview_on_a_face_sketch_in_a_mirrored_component_sits_at_the_sketch() { + let (mut s, component, occurrence) = ear(true); + let (commands, sketch) = two_circle_cut_on_face(&mut s, component, occurrence, [30.0, 30.0, 10.0]); + let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::dark()); + let built = crate::preview::compute(&s, &commands, colors).unwrap(); + assert_tool_at(&built, [30.0, 30.0, 7.5], [20.0, 10.0, 7.5]); + let FeatureKind::Sketch { plane: PlaneRef::Face { .. }, .. } = s.doc.feature(sketch).unwrap().kind else { panic!("a sketch on a face") }; +} From a5a561caaecc3abdb6049784cd8975b49db74662 Mon Sep 17 00:00:00 2001 From: Marcel Karger Date: Sun, 1 Nov 2026 12:57:15 +0000 Subject: [PATCH 2/6] Add Creo Parametric navigation preset (#3507) (#9322) * claim: #3507 Co-authored-by: Codex * Add Creo Parametric navigation preset (#3507) Add middle-button orbit, Shift+middle pan and Ctrl+middle vertical drag zoom, with downward dragging zooming in. Keep the default navigation preset and existing presets unchanged. Refs #3507 Co-authored-by: Codex * Navigation: Creo preset needs the NX preset's reverse_drag_zoom field Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codex Co-authored-by: bherbruck Co-authored-by: Claude Opus 5.5 --- assets/locales/pending/creo-navigation.json | 34 ++++++ crates/ui-egui/src/mouse_gesture.rs | 2 +- crates/ui-egui/src/navigation.rs | 3 +- crates/ui-egui/src/prefs.rs | 108 +++++++++++++++++- crates/ui-egui/src/prefs/navigation.rs | 2 +- .../scenarios/creo_navigation_bindings.json | 29 +++++ docs/control-protocol.md | 2 +- 7 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 assets/locales/pending/creo-navigation.json create mode 100644 crates/ui-egui/tests/scenarios/creo_navigation_bindings.json diff --git a/assets/locales/pending/creo-navigation.json b/assets/locales/pending/creo-navigation.json new file mode 100644 index 0000000000..878796cf9d --- /dev/null +++ b/assets/locales/pending/creo-navigation.json @@ -0,0 +1,34 @@ +{ + "ar": { + "nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الزر الأوسط يحرّكه، وCtrl مع السحب العمودي بالزر الأوسط يكبّر أو يصغّر (السحب لأسفل للتكبير)؛ وتدوير عجلة الفأرة يكبّر أو يصغّر", + "nav.creo.label": "Creo" + }, + "de": { + "nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt", + "nav.creo.label": "Creo" + }, + "en": { + "nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom", + "nav.creo.label": "Creo" + }, + "he": { + "nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום", + "nav.creo.label": "Creo" + }, + "ja": { + "nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム", + "nav.creo.label": "Creo" + }, + "ko": { + "nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다", + "nav.creo.label": "Creo" + }, + "vi": { + "nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng", + "nav.creo.label": "Creo" + }, + "zh": { + "nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放", + "nav.creo.label": "Creo" + } +} diff --git a/crates/ui-egui/src/mouse_gesture.rs b/crates/ui-egui/src/mouse_gesture.rs index a11ece4b0e..478d9c2a76 100644 --- a/crates/ui-egui/src/mouse_gesture.rs +++ b/crates/ui-egui/src/mouse_gesture.rs @@ -1153,7 +1153,7 @@ mod tests { let mut app = app(); assert!(!active(&app), "off by default"); app.preferences.mouse_gestures.enabled = true; - for nav in ["fusion", "inventor", "alias", "blender", "tinkercad", "openscad", "touchpad"] { + for nav in ["fusion", "creo", "inventor", "alias", "blender", "tinkercad", "openscad", "touchpad"] { app.preferences.nav = nav.into(); assert!(!active(&app), "{nav}: gestures only work with the SolidWorks mouse mode"); } diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 31d013891d..dc15065519 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -815,7 +815,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e Some(NavMode::Zoom) => { let press = ui.input(|i| i.pointer.press_origin()); let anchor = model_zoom_anchor(app, rect, press.or(hover), caps.orbit); - app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), anchor); + let sign = if crate::prefs::nav_preset(&app.preferences.nav).reverse_drag_zoom { -1.0 } else { 1.0 }; + app.cam.zoom_at((f64::from(delta.y) * 0.01 * sign).exp(), anchor); } _ => {} } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index bd990f6d9e..b8efe27b9c 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -89,6 +89,8 @@ pub struct NavPreset { pub help: &'static str, /// Choosing the preset turns the wheel the other way. pub reverse_zoom: bool, + /// Dragging down zooms in instead of out (independent of the wheel preference). + pub reverse_drag_zoom: bool, /// Choosing the preset sets what a middle double-click does (`None` leaves it as it is). pub middle_double_click: Option<&'static str>, /// Choosing the preset sets "Right drag orbits" (`None` leaves it as it is). @@ -113,6 +115,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Fusion", help: "Middle drag pans, Shift+middle orbits, right drag orbits", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[bind(Middle, NavKeys::SHIFT, Orbit), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], @@ -122,6 +125,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "SolidWorks", help: "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", reverse_zoom: true, + reverse_drag_zoom: false, // Picked here, the right button is left to the context menu and mouse gestures (#6492). middle_double_click: Some("fit"), right_drag_orbits: Some(false), @@ -132,6 +136,18 @@ pub const NAV_PRESETS: &[NavPreset] = &[ bind(Right, NavKeys::NONE, Orbit), ], }, + // Public Creo Parametric dynamic-viewing bindings; no product code or assets are used. + // https://support.ptc.com/help/creo/creo_pma/r12/usascii/fundamentals/fundamentals/About_Dynamic_Viewing.html + NavPreset { + id: "creo", + label: "Creo", + help: "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom", + reverse_zoom: false, + reverse_drag_zoom: true, + middle_double_click: None, + right_drag_orbits: None, + bindings: &[bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::NONE, Orbit)], + }, NavPreset { id: "nx", label: "NX", @@ -140,6 +156,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ // middle drag with Ctrl (zoom) and Shift (pan). help: "Middle drag orbits, Shift+middle pans (NX's middle + right), Ctrl+middle zooms (NX's middle + left); right drag leaves the camera", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: Some("fit"), right_drag_orbits: Some(false), bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)], @@ -149,6 +166,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Inventor", help: "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[ @@ -163,6 +181,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Alias", help: "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[ @@ -179,6 +198,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Maya", help: "Alt+left drag orbits, Alt+middle pans, Alt+right zooms; middle drag also pans", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[ @@ -193,6 +213,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Blender", help: "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)], @@ -202,6 +223,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Tinkercad", help: "Right drag orbits, middle or Shift+right drag pans", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, Pan)], @@ -211,6 +233,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "OpenSCAD", help: "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[ @@ -226,6 +249,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "HFSS", help: "Alt+left drag pans, Shift+Alt+left zooms, Ctrl+left drag on empty space orbits", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, // Ctrl+left orbit is the drag from empty space every preset has: as a binding it would @@ -237,6 +261,7 @@ pub const NAV_PRESETS: &[NavPreset] = &[ label: "Touchpad", help: "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.", reverse_zoom: false, + reverse_drag_zoom: false, middle_double_click: None, right_drag_orbits: None, bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], @@ -257,7 +282,15 @@ impl NavPreset { } pub(crate) fn translated_label(&self) -> &str { - if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) } + match self.id { + "creo" => crate::i18n::tr("nav.creo.label"), + TOUCHPAD_NAV => crate::i18n::tr("nav.touchpad.label"), + _ => crate::i18n::tr_label(self.label), + } + } + + pub(crate) fn translated_help(&self) -> &str { + if self.id == "creo" { crate::i18n::tr("nav.creo.help") } else { crate::i18n::tr_label(self.help) } } } @@ -8536,6 +8569,79 @@ mod tests { assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit), "turned back on, the right drag orbits again"); } + #[test] + fn creo_preset_maps_navigation_and_persists() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + assert_eq!(app.preferences.nav, "fusion"); + choose_nav_preset(&mut app.preferences, nav_preset("creo")); + assert_eq!(app.preferences.nav, "creo"); + assert!(!app.preferences.zoom_reverse); + for (mods, mode) in [ + (Modifiers::NONE, NavMode::Orbit), + (Modifiers::SHIFT, NavMode::Pan), + (Modifiers::CTRL, NavMode::Zoom), + (Modifiers::COMMAND, NavMode::Zoom), + ] { + assert_eq!(nav_mode(&app, NavButton::Middle, mods), Some(mode)); + } + for button in [NavButton::Left, NavButton::Right] { + assert_eq!(nav_mode(&app, button, Modifiers::NONE), None); + } + assert!(nav_preset("creo").reverse_drag_zoom); + for preset in NAV_PRESETS.iter().filter(|p| p.id != "creo") { + assert!(!preset.reverse_drag_zoom, "{} keeps its drag zoom direction", preset.id); + } + let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + other.load_prefs(&app.prefs()); + assert_eq!(other.preferences.nav, "creo"); + assert_eq!(nav_mode(&other, NavButton::Middle, Modifiers::NONE), Some(NavMode::Orbit)); + } + + #[test] + fn creo_navigation_settings_show_localised_bindings() { + let _language = crate::i18n::scope("de"); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + control(&mut app, &json!({"open":"Navigation", "nav":"creo", "language":"de"})).unwrap(); + let ctx = crate::fonts::test_context(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1400.0, 1000.0)); + let mut textures = std::collections::HashMap::new(); + let mut draw = || { + let raw = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut output = ctx.run_ui(raw, |ui| show(&mut app, ui.ctx())); + crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta); + output.textures_delta.clear(); + output + }; + for _ in 0..3 { + draw(); + } + let output = draw(); + for label in ["Creo", crate::i18n::tr("prefs.gesture_orbit"), crate::i18n::tr("prefs.gesture_pan"), crate::i18n::tr("prefs.gesture_zoom")] { + assert!(crate::test_support::text_shapes(&output).any(|text| text.galley.text().contains(label)), "missing binding label: {label}"); + } + assert_ne!(nav_preset("creo").translated_help(), nav_preset("creo").help); + if let Ok(dir) = std::env::var("SOLIDCRAFT_CREO_EVIDENCE") { + std::fs::create_dir_all(&dir).unwrap(); + crate::ui_kit::test_raster(&ctx, output.shapes, output.pixels_per_point, screen, &textures) + .save(std::path::Path::new(&dir).join("creo-navigation-de.png")) + .unwrap(); + } + } + + #[test] + fn creo_preset_obeys_preference_transactions() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + control(&mut app, &json!({"open":"Navigation", "nav":"creo"})).unwrap(); + assert_eq!(app.preferences.nav, "creo"); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert_eq!(app.preferences.nav, "fusion"); + control(&mut app, &json!({"open":"Navigation", "nav":"creo", "button":"apply"})).unwrap(); + control(&mut app, &json!({"button":"defaults"})).unwrap(); + assert_eq!(app.preferences.nav, "fusion"); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert_eq!(app.preferences.nav, "creo"); + } + #[test] fn openscad_preset_maps_navigation_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 67f97e28cd..2abc578b49 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -61,7 +61,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } }); crate::scenario::publish_target("dialog:Pan, zoom, orbit", picker.rect); - picker.on_hover_text(crate::i18n::tr_label(nav_preset(&app.preferences.nav).help)); + picker.on_hover_text(nav_preset(&app.preferences.nav).translated_help()); }); }); }); diff --git a/crates/ui-egui/tests/scenarios/creo_navigation_bindings.json b/crates/ui-egui/tests/scenarios/creo_navigation_bindings.json new file mode 100644 index 0000000000..5e4487261f --- /dev/null +++ b/crates/ui-egui/tests/scenarios/creo_navigation_bindings.json @@ -0,0 +1,29 @@ +[ + {"note": "Creo navigation: choose the preset in Preferences, then orbit, pan and zoom using the middle button. Other presets keep their drag zoom direction."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}}, + {"call": "ui.prefs", "params": {"open": "Navigation"}}, + {"click": {"handle": "dialog:Pan, zoom, orbit"}}, + {"widget": "Creo"}, + {"expect": {"prefs": {"nav": "creo"}}}, + {"call": "ui.prefs", "params": {"button": "ok"}}, + {"call": "ui.prefs", "params": {"orbit_around": "view_centre", "zoom_to_cursor": false, "free_orbit": false}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 848, "y1": 500, "steps": 8, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 22, "pitch_deg": 0, "panned": false, "zoomed": "no"}, "selection": 0}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 860, "y1": 540, "steps": 8, "shift": true, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": true, "zoomed": "no"}}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 800, "y1": 560, "steps": 8, "ctrl": true, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "in"}}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 800, "y1": 440, "steps": 8, "ctrl": true, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "out"}}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 860, "y1": 540, "steps": 8, "button": "right"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "no"}, "selection": 0}}, + {"call": "ui.prefs", "params": {"nav": "blender"}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 800, "y1": 560, "steps": 8, "ctrl": true, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "out"}}} +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 316d863d4c..43440ed5ae 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -466,7 +466,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `max_step_file_mb` (default 512): the largest STEP or import file opened, in MB, from the default up to 16384 (16 GB) (Preferences › General › Largest file to open, #4086; a `--max-file-size` launch flag overrides both for that run; the web app keeps the defaults and refuses other values; [large files](book/src/guide/files.md#large-files)) - `middle_double_click` (`home` (default), `fit`, `off`) - `msaa` -- `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`) +- `nav` (`fusion`, `solidworks`, `creo`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`) - `nav_bridge_enabled` (default false) and `nav_bridge_port` (default 8765: a device's own app on this computer moves the view, [the navigation bridge](3d-mouse.md#device-apps-the-navigation-bridge)) - `navigator_fit` (default false: a view chosen on the view cube or the axis gizmo also looks at the middle of the model from the one distance that fits its bounding sphere in the free part of the view, at `home_fit_size`; Home and an opened design use the same zoom and centre while it is on, so views chosen after them only turn; #10091) - `offer_stand_up` From e7bb038795d0bddd410479f8b6cced05fcd794c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:27:53 +0000 Subject: [PATCH 3/6] Regenerate generated files and fold pending files [skip ci] --- assets/locales/ar.json | 2 ++ assets/locales/de.json | 2 ++ assets/locales/en.json | 2 ++ assets/locales/he.json | 2 ++ assets/locales/ja.json | 2 ++ assets/locales/ko.json | 2 ++ assets/locales/pending/creo-navigation.json | 34 --------------------- assets/locales/vi.json | 2 ++ assets/locales/zh.json | 2 ++ 9 files changed, 16 insertions(+), 34 deletions(-) delete mode 100644 assets/locales/pending/creo-navigation.json diff --git a/assets/locales/ar.json b/assets/locales/ar.json index a29bd4c394..129c84a0b2 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -1918,6 +1918,8 @@ "motion.track_name": "المسار {number}", "nav.alias.help": "Shift+Alt مع السحب بالزر الأيسر يدوّر العرض، وShift+Alt مع الأوسط يُزيحه، وShift+Alt مع الأيمن يكبّره؛ والسحب بالزر الأوسط يُزيح أيضًا والسحب بالأيمن يدوّر", "nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا", + "nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الزر الأوسط يحرّكه، وCtrl مع السحب العمودي بالزر الأوسط يكبّر أو يصغّر (السحب لأسفل للتكبير)؛ وتدوير عجلة الفأرة يكبّر أو يصغّر", + "nav.creo.label": "Creo", "nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.", "nav.free_orbit.label": "تدوير حر", "nav.free_orbit.snap_help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. اضغط مطولًا على Shift أثناء سحب التدوير لالتقاط اتجاه العرض واللف.", diff --git a/assets/locales/de.json b/assets/locales/de.json index 3bf5ea3a26..536b7ae89e 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1916,6 +1916,8 @@ "motion.track_name": "Spur {number}", "nav.alias.help": "Umschalt+Alt+Linksziehen dreht, Umschalt+Alt+Mitte verschiebt, Umschalt+Alt+Rechtsziehen zoomt; mittleres Ziehen verschiebt ebenfalls und rechtes Ziehen dreht", "nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert", + "nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt", + "nav.creo.label": "Creo", "nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.", "nav.free_orbit.label": "Freie Drehung", "nav.free_orbit.snap_help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollausrichtung bei. Halten Sie beim Ziehen zum Drehen die Umschalttaste gedrückt, um Blickrichtung und Rollausrichtung einrasten zu lassen.", diff --git a/assets/locales/en.json b/assets/locales/en.json index 3b4c30da30..9a76f0314a 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1918,6 +1918,8 @@ "motion.track_name": "Track {number}", "nav.alias.help": "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits", "nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone", + "nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom", + "nav.creo.label": "Creo", "nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.", "nav.free_orbit.label": "Free orbit", "nav.free_orbit.snap_help": "Mouse and 3D mouse rotate through both poles and preserve roll. Hold Shift during an orbit drag to snap the view direction and roll.", diff --git a/assets/locales/he.json b/assets/locales/he.json index f4825c4c09..f1b0ecf473 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.json @@ -1918,6 +1918,8 @@ "motion.track_name": "רצועה {number}", "nav.alias.help": "Shift+Alt וגרירה שמאלית מסובבים, Shift+Alt וגרירה אמצעית מזיזים, Shift+Alt וגרירה ימנית מבצעים זום; גם גרירה אמצעית מזיזה וגרירה ימנית מסובבת", "nav.blender.help": "גרירה אמצעית מסובבת, Shift וגרירה אמצעית מזיזים, Ctrl וגרירה אמצעית מבצעים זום; גרירה ימנית לא מזיזה את המצלמה", + "nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום", + "nav.creo.label": "Creo", "nav.free_orbit.help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. הצמדת הצירים עם Shift מושבתת.", "nav.free_orbit.label": "סיבוב חופשי", "nav.free_orbit.snap_help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. יש להחזיק את Shift בזמן גרירת סיבוב כדי להצמיד את כיוון התצוגה ואת הגלגול.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index dca67fecce..23e3808504 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1918,6 +1918,8 @@ "motion.track_name": "トラック {number}", "nav.alias.help": "Shift+Alt+左ドラッグで回転、Shift+Alt+中ドラッグでパン、Shift+Alt+右ドラッグでズーム。中ドラッグでもパン、右ドラッグで回転。", "nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。", + "nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム", + "nav.creo.label": "Creo", "nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。", "nav.free_orbit.label": "自由回転", "nav.free_orbit.snap_help": "マウスと3Dマウスで両極を越えて回転でき、ロールも保持されます。オービットのドラッグ中にShiftを押すと、視線方向とロールがスナップします。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 354c727f0f..c10e7f4e12 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1353,6 +1353,8 @@ "motion.track_name": "트랙 {number}", "nav.alias.help": "Shift+Alt+왼쪽 드래그는 회전, Shift+Alt+가운데 드래그는 이동, Shift+Alt+오른쪽 드래그는 확대/축소입니다. 가운데 드래그로도 이동하고 오른쪽 드래그로도 회전합니다", "nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다", + "nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다", + "nav.creo.label": "Creo", "nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.", "nav.free_orbit.label": "자유 회전", "nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.", diff --git a/assets/locales/pending/creo-navigation.json b/assets/locales/pending/creo-navigation.json deleted file mode 100644 index 878796cf9d..0000000000 --- a/assets/locales/pending/creo-navigation.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "ar": { - "nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الزر الأوسط يحرّكه، وCtrl مع السحب العمودي بالزر الأوسط يكبّر أو يصغّر (السحب لأسفل للتكبير)؛ وتدوير عجلة الفأرة يكبّر أو يصغّر", - "nav.creo.label": "Creo" - }, - "de": { - "nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt", - "nav.creo.label": "Creo" - }, - "en": { - "nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom", - "nav.creo.label": "Creo" - }, - "he": { - "nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום", - "nav.creo.label": "Creo" - }, - "ja": { - "nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム", - "nav.creo.label": "Creo" - }, - "ko": { - "nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다", - "nav.creo.label": "Creo" - }, - "vi": { - "nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng", - "nav.creo.label": "Creo" - }, - "zh": { - "nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放", - "nav.creo.label": "Creo" - } -} diff --git a/assets/locales/vi.json b/assets/locales/vi.json index 796c8314ed..f60921ed1b 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -1918,6 +1918,8 @@ "motion.track_name": "Rãnh {number}", "nav.alias.help": "Shift+Alt+kéo chuột trái để xoay, Shift+Alt+chuột giữa để trượt, Shift+Alt+chuột phải để thu phóng; kéo chuột giữa cũng trượt và kéo chuột phải xoay", "nav.blender.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để trượt, Ctrl+chuột giữa để thu phóng; kéo chuột phải giữ nguyên camera", + "nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng", + "nav.creo.label": "Creo", "nav.free_orbit.help": "Chuột và chuột 3D xoay tự do qua cả hai cực và giữ nguyên độ lăn nghiêng (Roll). Bắt dính trục bằng phím Shift sẽ bị tắt.", "nav.free_orbit.label": "Xoay góc nhìn tự do", "nav.free_orbit.snap_help": "Chuột và chuột 3D xoay qua cả hai cực và giữ nguyên góc nghiêng cuộn. Giữ Shift trong khi kéo xoay quỹ đạo để bắt dính hướng nhìn và góc nghiêng cuộn.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index d2f707ccc6..bf7da602fb 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1918,6 +1918,8 @@ "motion.track_name": "轨道 {number}", "nav.alias.help": "Shift+Alt+左键拖动旋转,Shift+Alt+中键拖动平移,Shift+Alt+右键拖动缩放;中键拖动也可平移,右键拖动旋转", "nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机", + "nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放", + "nav.creo.label": "Creo", "nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。", "nav.free_orbit.label": "自由旋转", "nav.free_orbit.snap_help": "鼠标和3D鼠标可越过两极旋转,并保留画面旋转角度。在拖动环绕时按住Shift,可吸附视图方向和画面旋转角度。", From e77c55549f96e806ca206945efb5f4afcf61cd14 Mon Sep 17 00:00:00 2001 From: Dahak Builds Date: Sun, 1 Nov 2026 13:31:14 +0000 Subject: [PATCH 4/6] Sketch: a value box must not swallow a click on the reference line's endpoint (#10600) (#10639) * claim: #10600 * Sketch: a value box must not swallow a click that lands on a sketch point (#10600) The construction line tools' Length box waits beside the pointer. When the pointer reaches the reference line's own endpoint it can land under that box (the box is anchored to the tool hover, which freezes while the pointer rests on the box), and the box's text edit takes the click, so the tool never sees it. A release over the tool's own value box or construction/normal switch that lands on a snapped sketch point now goes to the tool; a click on the box away from any sketch point still edits the box. --------- Co-authored-by: Dahak Builds --- .../ui-egui/src/sketch_construction_tests.rs | 22 +++++++++++++++++++ crates/ui-egui/src/sketch_dims.rs | 9 ++++++++ crates/ui-egui/src/viewport.rs | 19 ++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/crates/ui-egui/src/sketch_construction_tests.rs b/crates/ui-egui/src/sketch_construction_tests.rs index 404a500e2e..8154f61c1c 100644 --- a/crates/ui-egui/src/sketch_construction_tests.rs +++ b/crates/ui-egui/src/sketch_construction_tests.rs @@ -269,3 +269,25 @@ fn the_normal_switch_needs_a_length_and_draws_a_normal_line() { let curve = sk.curves.last().unwrap(); assert!(curve.construction && curve.infinite, "{curve:?}"); } + +#[test] +fn perpendicular_click_on_reference_endpoint_places_line() { + // #10600: after picking the reference line, a click exactly on that line's own endpoint + // must reach the tool and place the perpendicular through the endpoint. The Length box + // waits beside the pointer there, and its click must not swallow the endpoint's. + let mut h = sketch(); + let sid = h.app.session.active_sketch.unwrap(); + let before = h.app.session.doc.sketch(sid).unwrap().curves.len(); + steps(&mut h, "sketch.construction_line.perpendicular", &[[10., 0.], [20., 0.]]); + let sk = h.app.session.doc.sketch(sid).unwrap(); + assert_eq!(sk.curves.len(), before + 1, "endpoint click swallowed: tool={:?} status={:?}", h.app.tool, h.app.status); + let curve = sk.curves.last().unwrap(); + assert!(curve.infinite && curve.construction, "{curve:?}"); + let CurveKind::Line { a, b } = curve.kind else { panic!("a line") }; + let (p, q) = (sk.point(a).unwrap(), sk.point(b).unwrap()); + let d = q - p; + let end = sk.resolve_point("a.end").and_then(|i| sk.point(i)).unwrap(); + // Through a.end (20, 0) and perpendicular to a, which runs along the sketch x axis. + assert!((end - p).x.abs() < 1e-6, "not through a.end: {p:?} {q:?}"); + assert!(d.x.abs() < 1e-6, "not perpendicular to a: {d:?}"); +} diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index a846e5014f..bd549c2332 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -718,6 +718,15 @@ fn polygon_input_positions(bounds: egui::Rect, sizes: &[egui::Vec2], view: Optio /// About the size of a value box, for keeping it in view. pub(crate) const BOX: egui::Vec2 = egui::vec2(76.0, 24.0); +/// Whether `p` is over the active tool's own floating boxes: a value box or the construction +/// line's construction/normal switch. #10600: these must not swallow a click that lands on a +/// sketch point, so the viewport asks before letting one take the click. +pub(crate) fn over_tool_boxes(app: &SolveApp, ctx: &egui::Context, p: egui::Pos2) -> bool { + let Some(t) = app.tool.as_ref() else { return false }; + (0..t.dims.len()).any(|i| ctx.memory(|m| m.area_rect(egui::Id::new(("sc_dim_area", i)))).is_some_and(|r| r.contains(p))) + || ctx.memory(|m| m.area_rect(egui::Id::new("sc_construction_switch"))).is_some_and(|r| r.contains(p)) +} + /// Where a value box whose top left would be `pos` goes so it stays in the drawing area `view`: /// as it is when it fits; else mirrored through the pointer (a box set off the cursor, past the /// view's edge, goes to the cursor's other side, still clear of it, #2201); else pushed inside. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 95a77fa87c..b33f196da0 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -4546,6 +4546,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { && app.viewport.draw_drag.is_none(); let resting = hover.filter(|p| free_pointer && rect.contains(*p) && !axes.contains(*p) && !crate::shell::covered(app, *p)); crate::sketch_line_arc::follow_pointer(app, ui.ctx(), &proj, resting); + // #10600: a running tool's own floating value box (or its construction/normal switch) must + // not swallow a click that lands on a sketch point: the click places the point there, and + // the box keeps the keyboard for typed values. A click on the box away from any sketch point + // still edits the box. The box is anchored to the tool's hover, which freezes while the + // pointer rests on the box, so a point the pointer reaches from the box's side can sit under + // it; the snap identifies the intended point even there. + if !inside + && app.tool.is_some() + && !held + && primary_nav.is_none() + && mode.is_none() + && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) + && let Some(p) = hover.filter(|p| rect.contains(*p)) + && crate::sketch_dims::over_tool_boxes(app, ui.ctx(), p) + && sketch_point_at(app, &proj, p).is_some_and(|(_, id)| id.is_some()) + { + crate::tools::on_hover(app, &proj, p, mods.shift); + crate::tools::on_click(app, &proj, p, mods.shift); + } if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p, mods.shift); From 2ab5dedfc13ba5ad56b3766bfbc3c1ebbbc6938a Mon Sep 17 00:00:00 2001 From: ABDULLAH <149819669+A-AL-ANAZI@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:13:48 +0000 Subject: [PATCH 5/6] Arabic: pan term in the Creo navigation help (#10667) --- assets/locales/ar.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/locales/ar.json b/assets/locales/ar.json index 129c84a0b2..2ed3d89646 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -1918,7 +1918,7 @@ "motion.track_name": "المسار {number}", "nav.alias.help": "Shift+Alt مع السحب بالزر الأيسر يدوّر العرض، وShift+Alt مع الأوسط يُزيحه، وShift+Alt مع الأيمن يكبّره؛ والسحب بالزر الأوسط يُزيح أيضًا والسحب بالأيمن يدوّر", "nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا", - "nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الزر الأوسط يحرّكه، وCtrl مع السحب العمودي بالزر الأوسط يكبّر أو يصغّر (السحب لأسفل للتكبير)؛ وتدوير عجلة الفأرة يكبّر أو يصغّر", + "nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع السحب العمودي بالأوسط يكبّره أو يصغّره (السحب لأسفل للتكبير)؛ وتدوير العجلة يكبّره أو يصغّره", "nav.creo.label": "Creo", "nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.", "nav.free_orbit.label": "تدوير حر", From 176c22acaa05cc7479cb63c1034327645617d735 Mon Sep 17 00:00:00 2001 From: Dahak Builds Date: Sun, 1 Nov 2026 15:36:04 +0000 Subject: [PATCH 6/6] MCP: explain when an unknown command id is actually an MCP tool name (#10678) (#10680) * claim: #10678 * MCP: explain when an unknown command id is actually an MCP tool name --------- Co-authored-by: Dahak Builds --- crates/mcp/src/tests.rs | 71 +++++++++++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 32 ++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 39918e9886..23957f826d 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1271,6 +1271,77 @@ fn tool_unknown_command_errors_explain_packed_params() { assert!(body["failed"]["error"].as_str().unwrap().contains("command ids have no parameters; pass them in `params`"), "{body}"); } +/// #10678: through direct `execute`, an unknown command id that is exactly a registered +/// MCP tool name gets the namespace hint instead of unrelated closest-command suggestions. +#[test] +fn tool_name_command_ids_get_namespace_hint_via_execute() { + let mut s = server(); + for id in ["execute", "list_commands", "describe_command", "screenshot"] { + let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.starts_with(&format!("unknown command `{id}`")), "{text}"); + assert!(text.contains(&format!("`{id}` is an MCP tool, not an engine command id")), "{text}"); + assert!(text.contains("tools/list") && text.contains("list_commands"), "{text}"); + assert!(!text.contains("closest:"), "{text}"); + } +} + +/// #10678: the direct `describe_command` hint inspects the requested `id`, not the outer +/// `engine.describe` command `exec` sees. +#[test] +fn tool_name_command_ids_get_namespace_hint_via_describe_command() { + let mut s = server(); + for id in ["execute", "list_commands"] { + let r = raw_tool(&mut s, "describe_command", json!({"id": id})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.starts_with(&format!("unknown command `{id}`")), "{text}"); + assert!(text.contains(&format!("`{id}` is an MCP tool, not an engine command id")), "{text}"); + assert!(!text.contains("engine.describe"), "{text}"); + } + // Control: a valid engine command id still describes itself. + let r = raw_tool(&mut s, "describe_command", json!({"id": "solid.box"})); + assert_ne!(r["result"]["isError"], true, "{r}"); +} + +/// #10678: an engine-id typo keeps its closest-command suggestions; the tool-name hint is +/// only for exact registered tool names. +#[test] +fn engine_command_id_typo_still_gets_closest_suggestions() { + let mut s = server(); + let r = raw_tool(&mut s, "execute", json!({"command": "solid.bxo"})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("closest: `solid.box`"), "{text}"); + assert!(!text.contains("MCP tool"), "{text}"); +} + +/// #10678: a genuinely unknown id keeps the ordinary unknown-command error on both paths. +#[test] +fn genuinely_unknown_command_id_keeps_the_ordinary_error() { + let mut s = server(); + for (name, args) in [("execute", json!({"command": "madeup.thing"})), ("describe_command", json!({"id": "madeup.thing"}))] { + let r = raw_tool(&mut s, name, args); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("unknown command `madeup.thing`"), "{text}"); + assert!(!text.contains("MCP tool"), "{text}"); + } +} + +/// #10678: a valid engine command id that overlaps a tool name resolves as a command — +/// the engine accepts it before any tool-name hint could apply. +#[test] +fn valid_engine_command_id_overlapping_a_tool_name_resolves_as_command() { + let mut b = Recorder { calls: Vec::new(), batch: Ok(json!({})) }; + let r = crate::tools::call_tool(&mut b, "execute", &json!({"command": "measure"})); + assert_ne!(r["isError"], json!(true), "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap().contains("measure"), "{r}"); + let r = crate::tools::call_tool(&mut b, "describe_command", &json!({"id": "measure"})); + assert_ne!(r["isError"], json!(true), "{r}"); +} + fn hostile_values() -> Vec { vec![ Value::Null, diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 79333e05e2..4c8c57cba1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -729,6 +729,11 @@ fn command_error(command: &str, error: String) -> String { return error; } let id = command.split(['{', '(']).next().unwrap_or(command).trim(); + // An id that is exactly a registered tool name confuses the two namespaces: name the + // distinction and its recovery instead of offering unrelated closest-command suggestions. + if id == command.trim() && is_tool_name(id) { + return tool_name_error(id); + } let packed = id != command.trim(); let normalize = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::(); let normalized = normalize(id); @@ -762,6 +767,29 @@ fn command_error(command: &str, error: String) -> String { message } +/// Whether `name` is exactly a registered MCP tool name, read from the tool definitions +/// themselves so the two namespaces can never drift apart. +fn is_tool_name(name: &str) -> bool { + tool_definitions().iter().any(|t| t["name"].as_str() == Some(name)) +} + +/// The reply when a rejected command id is exactly a tool name: the recovery belongs to the +/// tool namespace, not to a typo's closest-command suggestions. +fn tool_name_error(id: &str) -> String { + format!( + "unknown command `{id}`: `{id}` is an MCP tool, not an engine command id. Use MCP tools/list for its tool schema, or list_commands for engine command ids." + ) +} + +/// A `describe_command` rejection of an id that is exactly a tool name gets the namespace +/// hint; every other error is left exactly as the engine said it. +fn describe_error(id: &str, error: String) -> String { + if error.starts_with(&format!("unknown command `{id}`")) && is_tool_name(id) { + return tool_name_error(id); + } + error +} + fn command_distance(a: &str, b: &str) -> usize { let mut row: Vec = (1..=b.len()).collect(); for (i, ca) in a.bytes().enumerate() { @@ -808,7 +836,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { let id = str_arg(a, "id").ok_or("`id` is required")?; - exec(b, "engine.describe", json!({"command": id})) + // The engine rejects the requested id inside its own error, so the tool-name + // hint must inspect the `id`, not the outer `engine.describe` command `exec` sees. + exec(b, "engine.describe", json!({"command": id})).map_err(|e| describe_error(id, e)) } "execute" => { let id = str_arg(a, "command").ok_or("`command` is required")?;