Assembly Move: drag allowed joint freedoms (#6679) (#6904)

* feat: add joint-aware assembly Move handles and live motion readouts

* test: improve render job failure diagnostics

* fix: preserve assembly Move compatibility and viewport theme colors

* ci: skip default label sync for fork PRs

* fix: keep Windows CI checks portable after main rebase

* test: cover RTL assembly strings and stable refraction job

* i18n: cover assembly Move in Vietnamese

* i18n: cover assembly Move in French

* fix: complete CGA palette for assembly motion handles

* fix: complete Assembly Move palettes and Turkish strings

* fix: retain assembly move cache answers and toolbar help

* style: format merged assembly Move navigation guard

* fix: align Assembly Move preferences and tests with main

* Fix offline update fixture synchronization and clipped prism intersections

Wait for all v5 manifest requests before deploying v6. Intersect hole-free prism sections over their common height, retaining the established B-rep path for newly clipped perforated sections. Preserve the failing property seed and add closed-form and cap-boundary regressions.
This commit is contained in:
cronos
2026-10-29 13:57:31 +00:00
committed by GitHub
parent 986de7ec9a
commit 28e26209f3
35 changed files with 1791 additions and 31 deletions
+1
View File
@@ -52,6 +52,7 @@ jobs:
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
name: Label changes-default
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
continue-on-error: true
@@ -0,0 +1,146 @@
{
"en": {
"help.occurrence.move": "Moves an assembly part only along freedoms allowed by its joints. Select a part face, then drag an available arrow or arc.",
"joint_move.blocked_hint": "Check the joint type (such as Rigid), grounding, joint limits, and parameter-driven values.",
"joint_move.blocked_title": "Cannot move this part",
"joint_move.coarse": "Near-axis rotation step (°)",
"joint_move.distance": "Distance from start",
"joint_move.done": "Done",
"joint_move.fine": "Far-axis rotation step (°)",
"joint_move.fixed": "This part has no movable joint axis.",
"joint_move.help": "Move — drag an arrow or ring. Esc cancels the current drag.",
"joint_move.legacy_hint": "Turn off to use the previous Move panel.",
"joint_move.precision": "Pull outside the dashed guide for fine rotation.",
"joint_move.rotation": "Rotation from start",
"joint_move.select": "Select a face or one assembly part, then choose Move.",
"joint_move.settings": "Assembly Move",
"joint_move.step": "Step",
"joint_move.use_handles": "Use joint motion handles for assembly Move"
},
"ar": {
"help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.",
"joint_move.blocked_hint": "تحقق من نوع المفصل (مثل المفصل الثابت)، وتثبيت الجزء، وحدود المفصل، والقيم التي تتحكم بها المعلمات.",
"joint_move.blocked_title": "لا يمكن تحريك هذا الجزء",
"joint_move.coarse": "خطوة الدوران قرب المحور (°)",
"joint_move.distance": "المسافة من نقطة البداية",
"joint_move.done": "تم",
"joint_move.fine": "خطوة الدوران بعيدًا عن المحور (°)",
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
"joint_move.legacy_hint": "أوقف هذا الخيار لاستخدام لوحة التحريك السابقة.",
"joint_move.precision": "اسحب خارج الدليل المتقطع لضبط الدوران بدقة.",
"joint_move.rotation": "الدوران من نقطة البداية",
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
"joint_move.settings": "تحريك التجميع",
"joint_move.step": "الخطوة",
"joint_move.use_handles": "استخدام مقابض حركة المفاصل لتحريك التجميع"
},
"fr": {
"help.occurrence.move": "Déplace une pièce d’assemblage selon les seules libertés permises par ses liaisons. Sélectionnez une face de la pièce, puis faites glisser une flèche ou un arc disponible.",
"joint_move.blocked_hint": "Vérifiez le type de liaison (par exemple rigide), la fixation de la pièce, les limites de la liaison et les valeurs pilotées par des paramètres.",
"joint_move.blocked_title": "Impossible de déplacer cette pièce",
"joint_move.coarse": "Pas de rotation près de l’axe (°)",
"joint_move.distance": "Distance depuis le départ",
"joint_move.done": "Terminé",
"joint_move.fine": "Pas de rotation loin de l’axe (°)",
"joint_move.fixed": "Cette pièce n’a aucun axe de liaison mobile.",
"joint_move.help": "Déplacer — faites glisser une flèche ou un arc. Esc annule le glissement en cours.",
"joint_move.legacy_hint": "Désactivez cette option pour utiliser l’ancien panneau Déplacer.",
"joint_move.precision": "Tirez au-delà du repère en pointillés pour ajuster finement la rotation.",
"joint_move.rotation": "Rotation depuis le départ",
"joint_move.select": "Sélectionnez une face ou une pièce de l’assemblage, puis choisissez Déplacer.",
"joint_move.settings": "Déplacement dans l’assemblage",
"joint_move.step": "Pas",
"joint_move.use_handles": "Utiliser les poignées de mouvement des liaisons pour déplacer dans l’assemblage"
},
"he": {
"help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.",
"joint_move.blocked_hint": "בדקו את סוג המפרק (למשל מפרק קשיח), את קיבוע החלק, את מגבלות המפרק ואת הערכים הנשלטים על ידי פרמטרים.",
"joint_move.blocked_title": "אי אפשר להזיז את החלק הזה",
"joint_move.coarse": "צעד סיבוב קרוב לציר (°)",
"joint_move.distance": "מרחק מנקודת ההתחלה",
"joint_move.done": "סיום",
"joint_move.fine": "צעד סיבוב רחוק מהציר (°)",
"joint_move.fixed": "לחלק הזה אין ציר מפרק שניתן להזיז.",
"joint_move.help": "הזזה — גררו חץ או קשת. Esc מבטל את הגרירה הנוכחית.",
"joint_move.legacy_hint": "כבו כדי להשתמש בחלונית ההזזה הקודמת.",
"joint_move.precision": "משכו אל מחוץ למנחה המקווקו כדי לכוון את הסיבוב בעדינות.",
"joint_move.rotation": "סיבוב מנקודת ההתחלה",
"joint_move.select": "בחרו פאה או חלק אחד בהרכבה, ואז בחרו הזזה.",
"joint_move.settings": "הזזה בהרכבה",
"joint_move.step": "צעד",
"joint_move.use_handles": "שימוש בידיות תנועת מפרקים להזזה בהרכבה"
},
"ja": {
"help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。",
"joint_move.blocked_hint": "Rigid合致、部品の固定状態、ジョイントの制限値、パラメーターでの拘束を確認してください。",
"joint_move.blocked_title": "この部品は移動できません",
"joint_move.coarse": "軸に近い位置の回転刻み(°)",
"joint_move.distance": "開始位置からの移動距離",
"joint_move.done": "完了",
"joint_move.fine": "軸から遠い位置の回転刻み(°)",
"joint_move.fixed": "この部品には操作できるジョイントの自由度がありません。",
"joint_move.help": "移動 — 矢印または円弧をドラッグ。Escでドラッグを取消。",
"joint_move.legacy_hint": "オフにすると従来の移動パネルを使います。",
"joint_move.precision": "破線ガイドの外側へ引くと回転を微調整できます。",
"joint_move.rotation": "開始位置からの回転角",
"joint_move.select": "面またはアセンブリ部品を1つ選択してから移動を実行してください。",
"joint_move.settings": "アセンブリの移動",
"joint_move.step": "刻み",
"joint_move.use_handles": "アセンブリの移動にジョイントの操作ハンドルを使う"
},
"tr": {
"help.occurrence.move": "Montaj parçasını yalnızca eklemlerinin izin verdiği serbestliklerde taşır. Bir parça yüzü seçtikten sonra kullanılabilir oku veya yayı sürükleyin.",
"joint_move.blocked_hint": "Eklem türünü (ör. Sabit), parçanın sabitlenme durumunu, eklem sınırlarını ve parametreyle belirlenen değerleri kontrol edin.",
"joint_move.blocked_title": "Bu parça taşınamıyor",
"joint_move.coarse": "Eksene yakın dönüş adımı (°)",
"joint_move.distance": "Başlangıçtan itibaren mesafe",
"joint_move.done": "Tamam",
"joint_move.fine": "Eksenden uzak dönüş adımı (°)",
"joint_move.fixed": "Bu parçanın hareket ettirilebilir bir eklem ekseni yok.",
"joint_move.help": "Taşı — oku veya yayı sürükleyin. Esc geçerli sürüklemeyi iptal eder.",
"joint_move.legacy_hint": "Önceki Taşı panelini kullanmak için kapatın.",
"joint_move.precision": "Dönüşü hassas ayarlamak için kesikli kılavuzun dışına sürükleyin.",
"joint_move.rotation": "Başlangıçtan itibaren dönüş",
"joint_move.select": "Bir yüz veya montajdaki bir parçayı seçin, ardından Taşı'yı seçin.",
"joint_move.settings": "Montajda Taşıma",
"joint_move.step": "Adım",
"joint_move.use_handles": "Montajda Taşıma için eklem hareket tutamaçlarını kullan"
},
"vi": {
"help.occurrence.move": "Chỉ di chuyển chi tiết lắp ráp theo các bậc tự do mà khớp nối cho phép. Chọn một mặt của chi tiết, rồi kéo mũi tên hoặc cung tròn có thể sử dụng.",
"joint_move.blocked_hint": "Kiểm tra loại khớp nối (chẳng hạn khớp nối cố định), trạng thái cố định của chi tiết, giới hạn khớp nối và các giá trị do tham số điều khiển.",
"joint_move.blocked_title": "Không thể di chuyển chi tiết này",
"joint_move.coarse": "Bước xoay khi ở gần trục (°)",
"joint_move.distance": "Quãng đường từ vị trí bắt đầu",
"joint_move.done": "Xong",
"joint_move.fine": "Bước xoay khi ở xa trục (°)",
"joint_move.fixed": "Chi tiết này không có trục khớp nối nào có thể di chuyển.",
"joint_move.help": "Di chuyển — kéo mũi tên hoặc cung tròn. Nhấn Esc để hủy thao tác kéo hiện tại.",
"joint_move.legacy_hint": "Tắt để sử dụng bảng Di chuyển trước đây.",
"joint_move.precision": "Kéo ra ngoài đường dẫn nét đứt để tinh chỉnh góc xoay.",
"joint_move.rotation": "Góc xoay từ vị trí bắt đầu",
"joint_move.select": "Chọn một mặt hoặc một chi tiết trong cụm lắp ráp, rồi chọn Di chuyển.",
"joint_move.settings": "Di chuyển trong cụm lắp ráp",
"joint_move.step": "Bước",
"joint_move.use_handles": "Dùng tay nắm chuyển động khớp nối khi di chuyển trong cụm lắp ráp"
},
"zh": {
"help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。",
"joint_move.blocked_hint": "请检查关节类型(例如 Rigid)、固定状态、关节限位和参数驱动值。",
"joint_move.blocked_title": "无法移动此零部件",
"joint_move.coarse": "靠近轴线时的旋转步长(°)",
"joint_move.distance": "相对起点的移动距离",
"joint_move.done": "完成",
"joint_move.fine": "远离轴线时的旋转步长(°)",
"joint_move.fixed": "此零部件没有可操作的关节自由度。",
"joint_move.help": "移动 — 拖动箭头或圆弧。按 Esc 取消当前拖动。",
"joint_move.legacy_hint": "关闭后使用原有的移动面板。",
"joint_move.precision": "将光标移到虚线引导圈外侧可微调旋转。",
"joint_move.rotation": "相对起点的旋转角度",
"joint_move.select": "请选择一个面或一个装配零部件,然后执行移动。",
"joint_move.settings": "装配移动",
"joint_move.step": "步长",
"joint_move.use_handles": "装配移动使用关节运动手柄"
}
}
+3
View File
@@ -175,6 +175,8 @@ impl Tokens {
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
joint_manipulator: Color32::from_rgb(235, 157, 0),
joint_axis: Color32::from_rgb(0, 95, 110),
preview_add: Color32::from_rgb(112, 160, 226),
preview_new: Color32::from_rgb(240, 225, 105),
preview_sketch: Color32::from_rgb(32, 190, 96),
@@ -352,6 +354,7 @@ 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.joint_axis = Color32::from_rgb(110, 240, 250);
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);
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#000000",
"icons.marker_halo": "#000000",
"interference": "#ffffff",
"joint_axis": "#ffffff",
"joint_manipulator": "#ffffff",
"logo": [
"#ffffff",
"#ffffff",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#ffffff",
"interference": "#000000",
"joint_axis": "#000000",
"joint_manipulator": "#000000",
"logo": [
"#000000",
"#000000",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#000000cc",
"interference": "#ff5555",
"joint_axis": "#aaaaaa",
"joint_manipulator": "#ffff55",
"logo": [
"#ffffff",
"#55ffff",
+4
View File
@@ -23,6 +23,10 @@ use solidcraft_sketch::linalg;
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
#[path = "joints_handles.rs"]
mod handles;
pub use handles::{MotionAxis, motion_axes};
/// Joints the solver (and the URDF export) reads at most.
pub const MAX_JOINTS: usize = 10_000;
+124
View File
@@ -0,0 +1,124 @@
//! The instantaneous motion a joint value gives a selected occurrence. Probe the same
//! placement graph and loop solver as a drive, including reversed joints and motion links.
use std::collections::BTreeSet;
use super::{Analysis, Held, MAX_LOOP_VALUES, Solution, apply_links, build, joint_values, link_chains, solve_holding_with_analysis};
use crate::{Document, Mat, ModelState, apply_point, apply_vector, mat_inverse, mat_mul};
use solidcraft_geom::Vec3;
/// A driveable value, expressed as an axis of the selected occurrence in world coordinates.
#[derive(Clone, Debug)]
pub struct MotionAxis {
pub joint: u64,
pub index: usize,
pub origin: Vec3,
/// Increasing the value moves/turns the selected occurrence along/about this unit axis.
pub axis: Vec3,
pub angular: bool,
/// Selected motion (mm or radians) per native joint-value unit (mm or radians).
pub scale: f64,
}
/// Independent values on this occurrence's anchored chain, excluding fixed, suppressed,
/// invalid and zero-range motion. Closed loops must admit the probe before an axis is shown.
/// This is a bounded query for an explicit Move operation, not a per-hover rank analysis.
pub fn motion_axes(doc: &Document, st: &ModelState, occurrence: u64) -> Vec<MotionAxis> {
let asm = &doc.assembly;
let values = joint_values(asm);
let mut report = Solution::default();
let built = build(doc, st, &values, &mut report);
let tree = &built.tree;
if tree.fixed.contains_key(&occurrence) || !tree.parent.contains_key(&occurrence) || !report.errors.is_empty() {
return Vec::new();
}
let Some(base) = tree.world(occurrence, &values) else { return Vec::new() };
let Some(inverse) = mat_inverse(&base) else { return Vec::new() };
let path: BTreeSet<u64> = tree.path(occurrence).1.iter().map(|(id, _)| *id).collect();
let linked: BTreeSet<_> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let mut variables: Vec<_> = tree
.path(occurrence)
.1
.iter()
.filter_map(|(id, _)| tree.joints.get(id))
.flat_map(|j| (0..j.kind.dofs().len()).map(move |i| (j.id, i)))
.filter(|v| !linked.contains(v))
.collect();
variables.extend(link_chains(asm, tree, &path, &linked).1);
let mut seen = BTreeSet::new();
variables.retain(|v| seen.insert(*v));
let reference = tree
.path(occurrence)
.1
.first()
.and_then(|(id, from_a)| {
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
let (owner, frame) = if *from_a { (j.b.occurrence, &frames.b) } else { (j.a.occurrence, &frames.a) };
Some(apply_point(&mat_mul(&tree.world(owner, &values)?, frame), Vec3::ZERO))
})
.unwrap_or_else(|| apply_point(&base, Vec3::ZERO));
let mut out: Vec<MotionAxis> = Vec::new();
for (id, index) in variables.into_iter().take(MAX_LOOP_VALUES) {
let Some(joint) = tree.joints.get(&id) else { continue };
// A parameter expression constrains this value; moving it must not silently erase it.
if joint.exprs.value(index).is_some() {
continue;
}
let Some(q) = values.get(&id).and_then(|q| q.get(index)).copied() else { continue };
for requested in [1e-3, -1e-3] {
let mut probe = values.clone();
let Some(slot) = probe.get_mut(&id).and_then(|v| v.get_mut(index)) else { break };
*slot = q + requested;
if let Some((lo, hi)) = joint.limits.get(index).copied().flatten() {
*slot = (*slot).clamp(lo.min(hi), lo.max(hi));
}
let step = *slot - q;
if step.abs() < 1e-10 {
continue;
}
apply_links(asm, &mut probe);
let next = if built.closing.is_empty() {
tree.world(occurrence, &probe)
} else {
let mut changed = doc.clone();
for j in &mut changed.assembly.joints {
if let Some(q) = probe.get(&j.id) {
j.values.clone_from(q);
}
}
let held = Held::from([((id, index), q)]);
let solved = solve_holding_with_analysis(&changed, st, &held, Analysis::Placement);
if solved.conflicts.is_empty() && solved.errors.is_empty() { solved.world.get(&occurrence).copied() } else { None }
};
let Some(next) = next else { continue };
let delta = mat_mul(&next, &inverse);
if let Some(axis) = axis_of(delta, step, reference, id, index) {
// Coincident values describe the same visible motion; retain the nearest joint.
if !out.iter().any(|a| {
a.angular == axis.angular
&& a.axis.dot(axis.axis).abs() > 1.0 - 1e-6
&& (!a.angular || (a.origin - axis.origin).cross(a.axis).len() < 1e-4)
}) {
out.push(axis);
}
break;
}
}
}
out
}
fn axis_of(delta: Mat, step: f64, reference: Vec3, joint: u64, index: usize) -> Option<MotionAxis> {
let (x, y, z) = (apply_vector(&delta, Vec3::X), apply_vector(&delta, Vec3::Y), apply_vector(&delta, Vec3::Z));
let sine = Vec3::new(y.z - z.y, z.x - x.z, x.y - y.x);
let angle = sine.len().atan2(x.x + y.y + z.z - 1.0);
let translation = apply_point(&delta, Vec3::ZERO);
let (origin, axis, angular, scale) = if angle > 1e-8 {
let axis = sine.normalized()? * step.signum();
let transverse = translation - axis * translation.dot(axis);
let centre = (transverse + axis.cross(transverse) / (angle * step.signum() * 0.5).tan()) * 0.5;
(centre + axis * reference.dot(axis), axis, true, angle / step.abs())
} else {
(reference, (translation / step).normalized()?, false, translation.len() / step.abs())
};
(origin.is_finite() && axis.is_finite() && scale.is_finite() && scale > 1e-8).then_some(MotionAxis { joint, index, origin, axis, angular, scale })
}
+36 -7
View File
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
the occurrence named)",
the occurrence named); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
),
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",
@@ -858,7 +858,27 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
if kind == JointKind::Rigid {
return Err(bad(cmd, "a rigid joint does not move: joint.edit can make it revolute (turns), slider (slides) or cylindrical (both)"));
}
let mut q = values_param(s, p, kind, cmd)?;
let index = match p.get("index") {
None => None,
Some(v) => Some(
v.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < kind.dofs().len())
.ok_or_else(|| bad(cmd, "index is outside this joint's coordinates"))?,
),
};
let mut q = if let Some(index) = index {
if p.get("values").is_some() {
return Err(bad(cmd, "index goes with value, not values"));
}
let mut q = joints::joint_values(&s.doc.assembly).remove(&id).unwrap_or_else(|| vec![0.0; kind.dofs().len()]);
if let Some(slot) = q.get_mut(index) {
*slot = value(s, p.get("value").ok_or_else(|| bad(cmd, "give value"))?, kind.is_angle(index), cmd)?;
}
q
} else {
values_param(s, p, kind, cmd)?
};
if p.get("values").is_none() && p.get("value").is_none() {
return Err(bad(cmd, "give `value` or `values`"));
}
@@ -869,23 +889,32 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => a.len(),
_ => 1,
};
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
if index.is_none() {
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
}
}
joints::clamp(j, &mut q);
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
// `values` are not driven: in a closed loop they follow.
// Driving moves the joint by hand: the values driven drop their expressions (an expression
// given here is worked out once, as contact or a loop may stop the motion short of it).
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
if let Some(index) = index {
j.exprs.set_value(index, None);
} else {
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
}
}
let before = super::motion::positions(s);
let r = super::motion::drive_joint(s, id, q, given)?;
let r = if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? };
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(effective_values(s, id));
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
+31
View File
@@ -2208,3 +2208,34 @@ fn a_parameter_change_solves_the_joints_once() {
assert!(super::solve_counts::take().0 > 0);
assert!(near(place(&s, "Bracket", Vec3::new(110.0, 210.0, 0.0)), Vec3::new(180.0, 50.0, 1.0)));
}
#[test]
fn driving_one_cylindrical_coordinate_preserves_the_other_expression_and_reports_continuous() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "parameters.add", json!({"name":"turn","expression":"30 deg"}));
let joint = run(
&mut s,
"joint.create",
json!({"type":"cylindrical","a":{"occurrence":0,"point":[0,0,0]},"b":{"occurrence":"Part","point":[0,0,0]},"flip":true,"values":["turn",0]}),
)["joint"]
.as_u64()
.unwrap();
let before = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap().exprs.value(0).map(str::to_string);
assert!(before.is_some());
let reply = run(&mut s, "joint.drive", json!({"joint":joint,"index":1,"value":12,"continuous":true,"quiet":true}));
assert_eq!(reply["continuous"], true);
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert_eq!(j.exprs.value(0).map(str::to_string), before);
assert!((j.values[0].to_degrees() - 30.).abs() < 1e-7);
assert!((j.values[1] - 12.).abs() < 1e-7);
run(&mut s, "parameters.change", json!({"name":"turn","expression":"40 deg"}));
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert!((j.values[0].to_degrees() - 40.).abs() < 1e-7, "the preserved expression still drives the angle");
assert!((j.values[1] - 12.).abs() < 1e-7);
for bad in [json!(-1), json!(2), json!(1000000), json!("0")] {
assert!(s.execute("joint.drive", &json!({"joint":joint,"index":bad,"value":0})).is_err());
}
}
+5
View File
@@ -461,6 +461,11 @@ pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Resul
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
}
/// Drive exactly one coordinate, letting the other values follow loops and preserving expressions.
pub fn drive_joint_index(s: &mut Session, id: u64, q: Vec<f64>, index: usize) -> Result<Value> {
Ok(drive_with_contact(s, &[(id, q)], &[(id, index)].into_iter().collect())?.0)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_(p, "enabled").unwrap_or(true);
s.doc_mut().assembly.contact_enabled = on;
+1 -1
View File
@@ -1071,7 +1071,7 @@ fn dielectric_preflight_and_start_resolve_refraction_without_broadening_boundari
let status = handle.wait_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(
matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed),
"phase {:?}, error {:?}",
"phase {:?}, error {:?}; full status {status:?}",
status.progress.phase,
status.error
);
+48 -2
View File
@@ -1,7 +1,7 @@
//! Booleans of prisms in 2D: when the target is a prism (two caps square to one direction,
//! walls of planes and cylinders along it) and the tool a prism along the same direction
//! spanning the target's height, the result is the prism over the 2D boolean of their
//! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! sections. Hole-free intersections may instead use the common height interval. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! intersection's trouble with walls meeting along lines.
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
//! circular boss can meet the rim without shifting either operand off its exact plane.
@@ -2238,7 +2238,15 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
// Prisms of one section overlapping or touching end to end: one longer prism.
let stacked = op == BoolOp::Union && !same && pb.lo <= pa.hi + tol && pa.lo <= pb.hi + tol;
let ok = match op {
BoolOp::Cut | BoolOp::Intersect => spans,
BoolOp::Cut => spans,
// An intersection keeps only the common height. Unlike a through cut, its
// other operand need not span the target's entire extrusion.
BoolOp::Intersect => {
if pa.hi <= pb.lo || pb.hi <= pa.lo {
return Some(Ok(None));
}
true
}
BoolOp::Union => same || stacked,
};
if !ok {
@@ -2249,6 +2257,11 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
if regions.is_empty() {
return Some(Ok(None));
}
// Keep newly clipped perforated sections on the established B-rep route. Re-extruding
// a thin annulus with different circular seam splits can leave both cap meshes empty.
if op == BoolOp::Intersect && !spans && regions.iter().any(|r| !r.holes.is_empty()) {
return None;
}
let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions));
let alike = |v: f64| (u - v).abs() <= tol * (1.0 + u);
// A tool that misses its target (or an intersection that is one operand whole) leaves it
@@ -2269,6 +2282,8 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2
return Some(Ok(Some(body)));
}
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
} else if op == BoolOp::Intersect {
(pa.lo.max(pb.lo), pa.hi.min(pb.hi))
} else {
(pa.lo, pa.hi)
};
@@ -2585,6 +2600,37 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
mod planar_tests {
use super::*;
#[test]
fn single_prism_intersection_uses_common_height_in_both_orders() {
for k in [1.0, 0.05] {
for (lo, height, expected) in [
(7.5, 10.0, Some((7.5, 17.5))),
(15.0, 10.0, Some((15.0, 22.5))),
(-5.0, 15.0, Some((5.0, 10.0))),
(22.5, 10.0, None),
(25.0, 10.0, None),
] {
let a = crate::cylinder(Vec3::new(12.5, 5.0, 5.0) * k, Vec3::Z, 2.5 * k, 17.5 * k).unwrap();
let b = crate::box_solid(Vec3::new(10.0, 5.0, lo) * k, Vec3::new(22.5, 10.0, lo + height) * k).unwrap();
for (lhs, rhs) in [(&a, &b), (&b, &a)] {
let result = simple_prism_boolean(lhs, rhs, BoolOp::Intersect).expect("the exact prism route applies").unwrap();
if let Some((bottom, top)) = expected {
let body = result.expect("a positive-height half disc is a solid");
assert!(body.validity().is_empty());
assert!(body.history().is_some(), "clipped caps and walls retain their operand history");
let expected = std::f64::consts::PI * (2.5 * k).powi(2) * (top - bottom) * k / 2.0;
let actual = crate::measure(&body).unwrap().volume;
assert!((actual - expected).abs() < expected * 2e-3, "{lo} {height} {k}: {actual} != {expected}");
let bounds = body.tessellate(k * 1e-3).unwrap().bounds();
assert!((bounds.min.z - bottom * k).abs() < k * 1e-7);
assert!((bounds.max.z - top * k).abs() < k * 1e-7);
} else {
assert!(result.is_none(), "touching or separated caps have no intersection volume");
}
}
}
}
}
#[test]
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
@@ -7,3 +7,4 @@
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 0f8ee4d4597db6bdec2ad2cc7e5cfe7d4ebba351e24642118135da34bf489d0c # shrinks to a = Sph { at: [20.0, 10.0, 27.5], r: 3.75 }, b = Box { at: [15.0, 0.0, 22.5], size: [7.5, 20.0, 5.0] }
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] }
cc 00931f1657c5f31ab3593ef30eb6f452eddcf3fd0662d280ca2f6a86a409666b # shrinks to a = Cyl { at: [12.5, 5.0, 5.0], axis: 2, r: 2.5, h: 17.5 }, b = Box { at: [10.0, 5.0, 7.5], size: [12.5, 5.0, 10.0] }, k = 0.05
+23
View File
@@ -343,3 +343,26 @@ fn crossing_cylinders_union_moved_far() {
let there = run(&mv(&a), &mv(&b), BoolOp::Union).unwrap();
assert!(close(here, there, s), "union at origin {here:.4}, moved by 1000 gives {there:.4}");
}
/// The box takes half the cylinder's disc across 10 mm of its height. Its overlap is
/// pi * 2.5^2 * 10 / 2, including after a scale that makes the radius 0.125 mm.
#[test]
fn half_cylinder_intersection_scales_with_the_solid() {
let overlap = std::f64::consts::PI * 2.5f64.powi(2) * 10.0 / 2.0;
let cylinder_volume = std::f64::consts::PI * 2.5f64.powi(2) * 17.5;
let box_volume = 12.5 * 5.0 * 10.0;
for k in [1.0, 0.05] {
let a = Prim::Cyl { at: [12.5, 5.0, 5.0].map(|x| x * k), axis: 2, r: 2.5 * k, h: 17.5 * k }.body();
let b = Prim::Box { at: [10.0, 5.0, 7.5].map(|x| x * k), size: [12.5, 5.0, 10.0].map(|x| x * k) }.body();
for (op, expected) in
[(BoolOp::Union, cylinder_volume + box_volume - overlap), (BoolOp::Intersect, overlap), (BoolOp::Cut, cylinder_volume - overlap)]
{
let result = boolean(&a, &b, op)
.unwrap_or_else(|e| panic!("{op:?} at scale {k}: {e}"))
.unwrap_or_else(|| panic!("{op:?} at scale {k} is not empty"));
assert!(result.validity().is_empty(), "{op:?} at scale {k}: {:?}", result.validity());
let actual = measure(&result).unwrap().volume / (k * k * k);
assert!(close(actual, expected, expected), "{op:?} at scale {k}: {actual} != {expected}");
}
}
}
+2 -1
View File
@@ -275,7 +275,8 @@ pub(crate) fn chamfer_face_dirs(app: &SolveApp, d: &Dialog, at: Vec3) -> Option<
pub(crate) fn owns_pointer(app: &SolveApp, proj: &Proj, p: Pos2) -> bool {
// The triad only registers an egui hit widget after a handle is under the pointer.
// Test its geometry before navigation, including the first frame entering a handle.
app.dialog.as_ref().and_then(|d| crate::dialogs_move::triad(app, d)).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p))
crate::joint_handles::owns_pointer(app, proj, p)
|| app.dialog.as_ref().and_then(|d| crate::dialogs_move::triad(app, d)).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p))
|| app
.dialog
.as_ref()
+11
View File
@@ -299,6 +299,17 @@ fn groups(t: &Tokens) -> Vec<Group> {
group("UI text", &[("error", t.error), ("warning", t.warning), ("text", t.text)], &[("panel", t.panel)]),
// Every axis drawn: origin, view triad, navigation cube, Move gizmo… (one set, #976).
group("axes", &[("axis_x", t.axis_x), ("axis_y", t.axis_y), ("axis_z", t.axis_z)], &[("viewport_top", t.viewport_top)]),
group(
"assembly Move",
&[("joint_manipulator", t.joint_manipulator), ("joint_axis", t.joint_axis)],
&[
("viewport_top", t.viewport_top),
("viewport_bottom", t.viewport_bottom),
("body", t.body),
("sel_face", t.sel_face),
("body_selected", t.body_selected),
],
),
group("bend lines", &[("bend_up", t.bend_up), ("bend_down", t.bend_down)], &[("viewport_top", t.viewport_top)]),
group("box select", &[("box_window", t.box_window), ("box_crossing", t.box_crossing)], &[("viewport_top", t.viewport_top)]),
// Sketch dimensions (#7861): their lines and values, driving and driven, apart from the
+1
View File
@@ -12,6 +12,7 @@ pub const DOCUMENTED: &[&str] = &[
"component.finish_edit",
"face.delete",
"material.assign",
"occurrence.move",
"parameters.change",
"pointcloud.insert",
"solid.align",
+8
View File
@@ -227,6 +227,8 @@ pub struct MenuState {
pub rename: Option<Rename>,
/// A delete waiting for its confirmation (it takes or breaks other features).
pub confirm: Option<crate::delete::Pending>,
/// A selected part has no free joint value for the explicit assembly Move command.
pub joint_move_warning: Option<String>,
/// Identical copies Extract to File found, waiting to be linked too.
pub link_copies: Option<crate::linked_source::Copies>,
/// The Properties window: title and measurements.
@@ -607,6 +609,9 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "solid.extrude", "Extrude"));
v.push(cmd(app, "construct.plane.offset", "Offset Plane"));
v.push(cmd(app, "solid.shell", "Shell"));
if crate::joint_handles::selected_occurrence(app).is_some() {
v.push(cmd(app, "occurrence.move", "Move"));
}
v.push(act("ui.editFeature", "Edit Feature", "edit").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
let planar =
@@ -1491,6 +1496,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
"ui.moveOccurrence" => {
if let Some(o) = id_of(&p, "occurrence") {
if crate::joint_handles::begin(app, o) {
return;
}
let mut movement = crate::browser::OccMove::new(o);
movement.had_pending = app.session.pending_moves.contains_key(&o);
movement.path = p.get("path").and_then(Value::as_array).map(|p| p.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
File diff suppressed because it is too large Load Diff
+9
View File
@@ -98,6 +98,7 @@ pub mod insert_handles;
pub mod install;
pub mod issue_report;
pub mod joint_drag;
pub(crate) mod joint_handles;
pub mod keymap;
mod length_input;
mod linked_source;
@@ -1383,6 +1384,9 @@ impl SolveApp {
if self.file_load.busy() || self.file_picker.is_some() {
return;
}
if id != "occurrence.move" {
joint_handles::close(self);
}
// Repeat Last Command starts the tool it names; it is never the one repeated itself.
if id == "edit.repeat" {
self.repeat_last();
@@ -1434,6 +1438,10 @@ impl SolveApp {
return;
}
workspace::select_command_tab(self, id);
if id == "occurrence.move" {
joint_handles::start(self);
return;
}
// One selected placement moves as a component, the same way as its Browser menu,
// retaining the exact instance path and the grounding/rigid-group restrictions.
// A selected body, or a component picked in the tree, opens Move/Copy on those bodies.
@@ -2625,6 +2633,7 @@ impl SolveApp {
delete::show(self, ui.ctx());
easyprint_ui::show(self, ui.ctx());
linked_source::show_copies(self, ui.ctx());
joint_handles::show_warning(self, ui.ctx());
shortcut_box::show(self, ui.ctx());
prefs::show(self, ui.ctx());
toolbar_custom::window(self, ui.ctx());
+2 -1
View File
@@ -596,7 +596,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
return true;
}
let proj = projection(app, rect);
pick(app, &proj, p).is_empty()
!crate::canvas::owns_pointer(app, &proj, p)
&& pick(app, &proj, p).is_empty()
&& (crate::render_workspace::active(app) || (crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()))
})
};
+19
View File
@@ -948,6 +948,12 @@ preference_settings! {
max_step_file_mb: u64 = DEFAULT_STEP_MB, PAGE_GENERAL;
/// The largest design file opened or saved, in MB (#4086): from the default (256) up to 16 GB.
max_design_file_mb: u64 = DEFAULT_DESIGN_MB, PAGE_GENERAL;
/// Explicit assembly Move uses joint handles; off keeps the previous Move panel.
assembly_move_handles: bool = true, PAGE_DESIGN => check_id("joint_move.use_handles");
/// Degrees per step near the assembly Move ring.
joint_rotation_coarse: f64 = 10.0, PAGE_DESIGN;
/// Degrees per step after pulling the cursor outside the precision guide.
joint_rotation_fine: f64 = 0.1, PAGE_DESIGN;
/// `dark`, `light` or `system` (follow the operating system), or a theme file's id: a
/// built-in one (`gruvbox-dark`, …) or one of `custom_themes` (#2255).
theme: String = "dark".into(), PAGE_INTERFACE;
@@ -3030,6 +3036,12 @@ pub fn migrate(mut p: Prefs) -> Prefs {
let valid_resource = |(kind, id): &(String, String)| !kind.is_empty() && kind.len() <= 64 && !id.is_empty() && id.len() <= 4096;
p.render_library_favorites = p.render_library_favorites.into_iter().filter(valid_resource).take(256).collect();
p.render_library_recent = p.render_library_recent.into_iter().filter(valid_resource).take(32).collect();
if !p.joint_rotation_coarse.is_finite() || !(0.001..=180.0).contains(&p.joint_rotation_coarse) {
p.joint_rotation_coarse = 10.0;
}
if !p.joint_rotation_fine.is_finite() || !(0.001..=180.0).contains(&p.joint_rotation_fine) {
p.joint_rotation_fine = 0.1;
}
if p.orbit_cursor {
p.orbit_around = OrbitAround::Cursor;
p.orbit_cursor = false;
@@ -3105,6 +3117,13 @@ pub fn lenient(saved: &serde_json::Value) -> Prefs {
/// Set preferences from JSON (control channel); returns them all.
pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
for key in ["joint_rotation_coarse", "joint_rotation_fine"] {
if let Some(value) = p.get(key)
&& value.as_f64().is_none_or(|v| !v.is_finite() || !(0.001..=180.0).contains(&v))
{
return Err(format!("`{key}` must be 0.001…180 degrees"));
}
}
let mut cur = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
for (k, v) in src {
+13
View File
@@ -83,6 +83,19 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
setting::hide_origin(app, ui);
ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin"));
});
ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| {
setting::assembly_move_handles(app, ui);
ui_kit::text(ui, app.tr("joint_move.legacy_hint"), ui_kit::Text::Hint("joint_move_legacy"));
ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| {
ui_kit::row(ui, app.tr("joint_move.coarse"), |ui| {
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_coarse, 0.001..=180.0, 0.1);
});
ui_kit::row(ui, app.tr("joint_move.fine"), |ui| {
ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_fine, 0.001..=180.0, 0.01);
});
});
ui_kit::text(ui, app.tr("joint_move.precision"), ui_kit::Text::Hint("joint_move"));
});
// The Hole command's own settings, under its name.
ui_kit::settings_section(ui, "create_hole", crate::i18n::tr_label("Create Hole"), |ui| {
setting::hole_snaps(app, ui);
+7
View File
@@ -740,6 +740,10 @@ pub struct Tokens {
pub body_edge: Color32,
/// Command manipulators (direction arrows, handles).
pub manipulator: Color32,
/// Allowed assembly motion, separate from the selected face's blue.
pub joint_manipulator: Color32,
/// Assembly Move axis and centre, distinct from its draggable amber arrows.
pub joint_axis: 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).
@@ -915,6 +919,8 @@ impl Tokens {
body: view.body,
body_edge: view.body_edge,
manipulator: view.manipulator,
joint_manipulator: view.joint_manipulator,
joint_axis: view.joint_axis,
preview_add: view.preview_add,
preview_new: view.preview_new,
preview_sketch: view.preview_sketch,
@@ -1330,6 +1336,7 @@ mod tests {
(t.box_window, t.section_cap, t.preview_add, t.sketch_construction, t.continuity),
(v.box_window, v.section_cap, v.preview_add, v.sketch_construction, v.continuity)
);
assert_eq!((t.joint_manipulator, t.joint_axis), (v.joint_manipulator, v.joint_axis));
assert_eq!(t.analysis, v.analysis);
// The window's: panels, text, accents, dialogs, and the chips over the view with
// their borders and icons.
+1 -1
View File
@@ -304,7 +304,7 @@ theme_keys! {
app_bar, toolbar, panel, panel_header, border, text, text_dim, accent, accent_soft, sketch_accent, error, warning,
icon, timeline, hover, sel_face, sel_edge, sel_edge_rim, hover_edge, hover_edge_halo, sel_profile,
sel_profile_edge, hover_profile, hover_profile_edge, sel_vertex, origin_plane, origin_plane_edge, origin_plane_hover,
origin_point, axis_x, axis_y, axis_z, construction_plane, construction_geom, body, body_edge, manipulator,
origin_point, axis_x, axis_y, axis_z, construction_plane, construction_geom, body, body_edge, manipulator, joint_manipulator, joint_axis,
preview_add, preview_new, preview_sketch, preview_cut, cube_face, cube_border, cube_text, cube_hover, viewport_top,
viewport_bottom, grid, grid_major, body_selected, sketch, sketch_fixed, sketch_halo, sketch_locked, sketch_conflict, dimension, dimension_driven,
sketch_point, sketch_point_free, dialog_bg, dialog_border, chip, section_cap,
+1 -1
View File
@@ -2350,7 +2350,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
}
continue;
}
if crate::sketch_text_move::cancel_drag(app) || crate::occurrence_drag::cancel(app) {
if crate::joint_handles::cancel(app) || crate::sketch_text_move::cancel_drag(app) || crate::occurrence_drag::cancel(app) {
continue;
}
if crate::select_other::is_open(app) {
+24 -5
View File
@@ -208,6 +208,7 @@ pub struct ViewportState {
pub(crate) text_drag: Option<(u64, String)>,
/// A part that joints place, being dragged.
pub(crate) joint_drag: Option<crate::joint_drag::Drag>,
pub(crate) joint_handles: Option<crate::joint_handles::Mode>,
/// A free part being dragged to a new place (#2804).
pub(crate) occurrence_drag: Option<crate::occurrence_drag::Drag>,
/// Extrude by pulling a face or profile.
@@ -4096,6 +4097,7 @@ pub(crate) fn preview_should_roll_back(app: &SolveApp, held: crate::tool_modifie
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::joint_handles::sync(app);
let t = Tokens::get();
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
@@ -4151,9 +4153,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|| app.viewport.axis_drag.is_some()
|| app.viewport.spin
|| app.cam_anim.is_some();
let joint_handle_pointer = hover.is_some_and(|p| inside && crate::joint_handles::owns_pointer(app, &proj, p));
// A part dragged along its joint owns the pointer: nothing under it lights up (#5137).
let hits = hover
.filter(|_| inside && app.viewport.boxsel.is_none() && !crate::joint_drag::active(app) && !navigating)
.filter(|_| inside && app.viewport.boxsel.is_none() && !crate::joint_drag::active(app) && !joint_handle_pointer && !navigating)
.map(|p| pick_cached(app, &proj, p))
.unwrap_or_default();
// The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a
@@ -4173,7 +4176,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// held Space owns drags, not a still press, so only a navigation that has started cancels
// it. A preset's modifier binding (Maya Alt, Alias Shift+Alt) or Ctrl+left orbit is a
// gesture to navigate from the press on, so it still cancels the hold at once.
let navigating_from_press = mode.is_some() || app.viewport.primary_nav.is_some();
let navigating_from_press = mode.is_some() || app.viewport.primary_nav.is_some() || joint_handle_pointer;
let held = crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, navigating_from_press);
crate::select_other::hover_override(app);
let painter = ui.painter_at(rect);
@@ -4406,7 +4409,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
{
dialog.restore_value_focus = true;
}
if resp.clicked() && !held && !reference_pick {
if resp.clicked() && !held && !reference_pick && !joint_handle_pointer {
if app.tool.is_some() {
crate::tools::on_click(app, &proj, p, mods.shift);
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
@@ -4694,6 +4697,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
&& rect.contains(a)
&& !crate::shell::covered(app, a)
&& !axes.contains(a)
&& !crate::joint_handles::start_drag(app, &proj, a)
&& !crate::occurrence_drag::start(app, &proj, a)
&& !crate::joint_drag::start(app, &proj, a)
{
@@ -4702,7 +4706,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if resp.dragged_by(egui::PointerButton::Primary)
&& let Some(p) = hover
{
if crate::joint_drag::active(app) {
if crate::joint_handles::active(app) {
// Captured handle motion is handled below, including the release frame.
} else if crate::joint_drag::active(app) {
crate::joint_drag::to(app, &proj, p);
} else if crate::occurrence_drag::active(app) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
@@ -4710,6 +4716,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
}
if crate::joint_handles::active(app) && !crate::issue_report::cad_input_blocked() {
if let Some(p) = ui.input(|i| i.pointer.interact_pos()).filter(|p| p.is_finite()) {
crate::joint_handles::to(app, &proj, p);
}
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
crate::joint_handles::end_drag(app);
}
}
if crate::joint_drag::active(app) && (resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down())) {
crate::joint_drag::end(app);
}
@@ -4733,6 +4747,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
&& mode.is_none()
&& app.viewport.point_drag.is_none()
&& !crate::joint_drag::active(app)
&& !crate::joint_handles::active(app)
&& !joint_handle_pointer
&& !crate::occurrence_drag::active(app)
&& !crate::drag_extrude::started(app)
&& !crate::dim_view::dragging()
@@ -4765,7 +4781,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::ref_images::show(app, ui, &painter, &proj);
crate::point_clouds::show(app, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
crate::dialogs_assembly::joint_glyphs(app, &painter, &proj);
if !crate::joint_handles::visible(app) {
crate::dialogs_assembly::joint_glyphs(app, &painter, &proj);
}
crate::joint_handles::show(app, ui, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch_text_handles::show(app, ui, &painter, &proj, hover);
+3 -2
View File
@@ -152,8 +152,8 @@ pub const SECTIONS: &[Section] = &[
Section {
tab: "ASSEMBLE",
panel: "POSITION",
promoted: 3,
ids: &["occurrence.ground", "component.capture_position", "component.revert_position"],
promoted: 4,
ids: &["occurrence.move", "occurrence.ground", "component.capture_position", "component.revert_position"],
},
Section {
tab: "ASSEMBLE",
@@ -411,6 +411,7 @@ pub const RIBBON_LABELS: &[(&str, &str)] = &[
("inspect.zebra", "Zebra"),
("joint.as_built", "As-Built"),
("material.assign", "Material"),
("occurrence.move", "Move"),
("parameters.change", "Parameters"),
("parameters.graph", "Param. Graph"),
("parts.fastener", "Fastener"),
+187
View File
@@ -0,0 +1,187 @@
//! Review the real Move overlay and its readouts over the app's headless model render.
#[path = "support/raster.rs"]
mod raster;
use egui::{ColorImage, Event, PointerButton, Pos2, Rect, TextureId, vec2};
use serde_json::json;
use solidcraft_engine::{Sel, geom::Vec3};
use solidcraft_ui_egui::{
scenario::{self, Harness},
theme, viewport,
};
use std::collections::HashMap;
fn bearing(kind: &str) -> (Harness, u64) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("component.create", json!({"name":"Housing","grounded":true})).unwrap();
h.app.run("solid.box", json!({"length":80,"width":20,"height":80,"corner":[-40,0,0]})).unwrap();
h.app.run("solid.cylinder", json!({"base":[0,0,40],"axis":[0,1,0],"radius":24,"height":20,"operation":"cut"})).unwrap();
h.app.run("component.activate", json!({"component":"root"})).unwrap();
let part = h.app.run("component.create", json!({"name":"Bearing","grounded":false})).unwrap();
h.app.run("solid.cylinder", json!({"base":[0,-20,40],"axis":[0,1,0],"radius":23,"height":28})).unwrap();
h.app.run("solid.cylinder", json!({"base":[0,-20,40],"axis":[0,1,0],"radius":10,"height":28,"operation":"cut"})).unwrap();
h.app.run("component.activate", json!({"component":"root"})).unwrap();
let at = |name: &str| json!({"occurrence":name,"point":[0,0,40],"z":[0,1,0]});
let joint =
h.app.run("joint.create", json!({"type":kind,"a":at("Housing"),"b":at("Bearing"),"flip":true,"angle":0,"name":"Guide"})).unwrap()["joint"]
.as_u64()
.unwrap();
let st = h.app.session.world_state();
let body = st.bodies.iter().find(|b| h.app.session.doc.model_body_component(b) == part["component"].as_u64().unwrap()).unwrap();
let mesh = body.mesh();
let face = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find(|(t, _)| t.iter().all(|i| (mesh.positions[*i as usize].y + 20.).abs() < 1e-5))
.map(|(_, f)| *f as usize)
.unwrap();
h.app.session.selection = vec![Sel::Face { body: body.name.clone(), index: face, point: Vec3::new(15., -20., 40.) }];
h.call("ui.view", json!({"view":"iso"}));
h.app.cam.target = Vec3::new(0., 0., 40.);
h.app.cam.distance = 240.;
h.app.preferences.language = "ja".into();
(h, joint)
}
fn frame(h: &mut Harness, textures: &mut HashMap<TextureId, ColorImage>, events: Vec<Event>) -> egui::FullOutput {
raster::paint_at(h, textures, events, vec2(1460., 1000.))
}
fn save_frame(h: &mut Harness, textures: &mut HashMap<TextureId, ColorImage>, mut out: egui::FullOutput, path: &std::path::Path) {
let rect = h.app.viewport.rect.unwrap();
// This is the app's actual themed model render; handles and panels are actual egui output.
let png = solidcraft_ui_egui::control::view_png(&h.app, rect.width().round() as u32, rect.height().round() as u32).unwrap();
let model = image::load_from_memory(&png).unwrap().to_rgba8();
let id = TextureId::User(77);
textures.insert(id, ColorImage::from_rgba_unmultiplied([model.width() as usize, model.height() as usize], model.as_raw()));
let mut background = out
.shapes
.iter()
.position(|s| match &s.shape {
egui::Shape::Path(path) => path.stroke.width >= 13.5,
_ => false,
})
.expect("Move handle underlay");
// The dashed axis is painted just before the first arrow's underlay.
if background > 0 && matches!(&out.shapes[background - 1].shape, egui::Shape::Vec(_)) {
background -= 1;
}
out.shapes.insert(
background,
egui::epaint::ClippedShape {
clip_rect: rect,
shape: egui::Shape::image(id, rect, Rect::from_min_max(Pos2::ZERO, egui::pos2(1., 1.)), egui::Color32::WHITE),
},
);
raster::raster(h, textures, out).save(path).unwrap();
}
#[test]
fn joint_move_has_readable_rotation_and_translation_in_both_themes() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/joint-move-review");
std::fs::create_dir_all(&dir).unwrap();
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
for dark in [false, true] {
for (kind, name) in [("revolute", "rotation"), ("slider", "translation"), ("cylindrical", "combined")] {
let (mut h, joint) = bearing(kind);
h.app.ui.dark = dark;
h.app.preferences.theme = if dark { "dark" } else { "light" }.into();
h.ctx = egui::Context::default();
solidcraft_ui_egui::fonts::setup_fonts(&h.ctx);
theme::set_dark(dark);
theme::apply(&h.ctx);
let mut textures = HashMap::new();
raster::paint(&mut h, &mut textures, vec![]);
h.app.start("occurrence.move");
for _ in 0..8 {
frame(&mut h, &mut textures, vec![]);
}
let idle = frame(&mut h, &mut textures, vec![]);
let heads: Vec<_> = idle
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Path(path) if path.points.len() == 3 && path.fill == theme::Tokens::get().joint_manipulator => Some(&path.points),
_ => None,
})
.collect();
assert_eq!(heads.len(), if kind == "cylindrical" { 4 } else { 2 }, "two large heads per movable axis");
for p in heads {
let u = p[1] - p[0];
let v = p[2] - p[0];
assert!((u.x * v.y - u.y * v.x).abs() * 0.5 >= 400.0, "legible arrowhead, independent of arc tessellation");
}
save_frame(&mut h, &mut textures, idle, &dir.join(format!("{}-{name}-idle.png", if dark { "dark" } else { "light" })));
let from = scenario::handle_at(&format!("joint-move:{joint}:0")).expect("Move handle");
let rect = h.app.viewport.rect.unwrap();
let proj = viewport::projection(&h.app, rect);
let to = if kind != "slider" {
let origin = Vec3::new(0., -20., 40.);
let axis = Vec3::Y;
let (o, d) = proj.ray(from);
let q = o + d * ((origin - o).dot(axis) / d.dot(axis)) - origin;
let angle = 27.0_f64.to_radians();
proj.to_screen(origin + q * angle.cos() + axis.cross(q) * angle.sin()).unwrap()
} else {
from + (proj.to_screen(Vec3::Y * 18.75).unwrap() - proj.to_screen(Vec3::ZERO).unwrap())
};
frame(
&mut h,
&mut textures,
vec![
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Default::default() },
],
);
for k in 1..=12 {
frame(&mut h, &mut textures, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 12.))]);
}
let out = frame(&mut h, &mut textures, vec![]);
let readout = if kind != "slider" { "開始位置からの回転角" } else { "開始位置からの移動距離" };
assert!(
out.shapes.iter().any(|s| matches!(&s.shape,egui::Shape::Text(t) if t.galley.text().contains(readout))),
"visible relative readout"
);
save_frame(&mut h, &mut textures, out, &dir.join(format!("{}-{name}.png", if dark { "dark" } else { "light" })));
}
}
}
#[test]
fn joint_move_zero_degree_warning_is_readable_in_both_themes() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/joint-move-review");
std::fs::create_dir_all(&dir).unwrap();
for dark in [false, true] {
let (mut h, _) = bearing("rigid");
let before = h.app.session.doc.to_json();
h.app.ui.dark = dark;
h.app.preferences.theme = if dark { "dark" } else { "light" }.into();
h.ctx = egui::Context::default();
solidcraft_ui_egui::fonts::setup_fonts(&h.ctx);
theme::set_dark(dark);
theme::apply(&h.ctx);
let mut textures = HashMap::new();
for _ in 0..4 {
frame(&mut h, &mut textures, vec![]);
}
h.app.start("occurrence.move");
for _ in 0..6 {
frame(&mut h, &mut textures, vec![]);
}
let out = frame(&mut h, &mut textures, vec![]);
assert!(out.shapes.iter().any(|s| matches!(&s.shape,egui::Shape::Text(t) if t.galley.text().contains("この部品は移動できません"))));
assert!(out.shapes.iter().any(|s| matches!(&s.shape,egui::Shape::Text(t) if t.galley.text().contains("Rigid合致"))));
assert_eq!(h.app.session.doc.to_json(), before);
let rect = scenario::rect_of("dialog:window").unwrap();
let image = raster::raster(&mut h, &textures, out);
let x = (rect.min.x.max(0.) as u32).saturating_sub(8);
let y = (rect.min.y.max(0.) as u32).saturating_sub(8);
let width = (rect.width().ceil() as u32 + 16).min(image.width() - x);
let height = (rect.height().ceil() as u32 + 16).min(image.height() - y);
image::imageops::crop_imm(&image, x, y, width, height)
.to_image()
.save(dir.join(format!("{}-warning.png", if dark { "dark" } else { "light" })))
.unwrap();
}
}
+7 -2
View File
@@ -5,11 +5,15 @@ use solidcraft_ui_egui::scenario::Harness;
use std::collections::HashMap;
pub fn paint(h: &mut Harness, textures: &mut HashMap<TextureId, ColorImage>, events: Vec<egui::Event>) -> egui::FullOutput {
paint_at(h, textures, events, vec2(1100.0, 1500.0))
}
pub fn paint_at(h: &mut Harness, textures: &mut HashMap<TextureId, ColorImage>, events: Vec<egui::Event>, size: egui::Vec2) -> egui::FullOutput {
let time = h.ctx.cumulative_frame_nr() as f64 / 15.0;
let mut out = h.ctx.run_ui(
egui::RawInput {
time: Some(time),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1100.0, 1500.0))),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
focused: true,
events,
..Default::default()
@@ -41,7 +45,8 @@ pub fn paint(h: &mut Harness, textures: &mut HashMap<TextureId, ColorImage>, eve
pub fn raster(h: &mut Harness, textures: &HashMap<TextureId, ColorImage>, out: egui::FullOutput) -> image::RgbaImage {
let ppp = out.pixels_per_point;
let (width, height) = ((1100.0 * ppp) as u32, (1500.0 * ppp) as u32);
let size = h.ctx.viewport_rect().size();
let (width, height) = ((size.x * ppp) as u32, (size.y * ppp) as u32);
let mut pixels = vec![255u8; (width * height * 4) as usize];
let primitives = h.ctx.tessellate(out.shapes, out.pixels_per_point);
for clipped in primitives {
+21
View File
@@ -35,6 +35,27 @@ pending. Scripts and agents do the same with `occurrence.drag {occurrence or pat
which moves the placement by `point - at` in world coordinates and answers the placements it
`moved`.
**Move along the joint's free axes.** Select a face or a single assembly part, then choose
**Assemble → Move**, or right-click the selected face and choose **Move**. Thick straight
arrows move along the allowed axes; circular arrows turn about them. Grounded parts, rigid
joints, fixed coordinates and coordinates controlled by parameter expressions have no handle.
The handles appear only after Move is started and disappear when Measure or another command
starts. A face selected for measurement does not open Move.
If the selected part has no free joint value, Move shows a warning instead of handles.
Check rigid joints, grounding, joint limits and parameter-driven values; OK or Escape
dismisses the warning without changing those constraints.
While dragging, a readout shows the actual distance or angle from the drag's starting pose,
including when a joint limit or contact stops the part. Distances use the design's display
units. Rotation uses 10° steps near the ring; pull the pointer outside the dashed guide to
use 0.1° steps. Crossing the guide changes precision without jumping to another angle.
Change both step sizes under **Preferences → Design → Assembly Move**. Each drag is one
undo step; Escape while dragging restores its starting pose. **Done** (or Escape when idle)
closes the handles.
To use the previous Move panel, turn off **Use joint motion handles for assembly Move**
in the same settings section.
Paste New copies nested component definitions, sketches, feature histories and generated
dimension parameters. Repeated child instances remain shared within the new copy. Materials,
appearances and wholly internal joints/motion records receive copied references. Design-wide
+3
View File
@@ -339,6 +339,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
One setting per line, sorted by name. A change that adds a setting adds its own line in order, so two such changes only meet when their settings are neighbours.
- `angle_decimals`
- `assembly_move_handles` (default true): explicit Assembly Move uses the permitted joint-motion handles; false keeps the previous Move panel.
- `auto_operation` (default true: feature windows choose Join, New Body or Cut from what their material touches until one is picked; false: each keeps the operation it opens with, Extrude its own Join or Cut)
- `browser_bodies_folders` (`several` (default): a component with one body and no groups lists it straight under the component in the browser, without a Bodies folder; `always`; `never`: components list their bodies and groups straight under them, and the folder's menu items are on the component's menu; the design's own Bodies folder, and one hidden with its eye, always show; #4217)
- `check_updates` (default true; [updates](book/src/guide/updates.md))
@@ -353,6 +354,8 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `history_sketches` (`in_order` (default), `under_feature`: the History at the left lists a sketch under the first feature built from it, #6489; the timeline at the bottom keeps the creation order)
- `hole_moves` (default false: a placed Single hole has a drag handle (`hole:<n>`), and a pick on a face that has a hole moves it there)
- `hole_snaps` (default false: a Single hole snaps to its face's middle and round edges' centres)
- `joint_rotation_coarse` (default 10°): coarse Assembly Move rotation step, in 0.001…180°.
- `joint_rotation_fine` (default 0.1°): fine Assembly Move rotation step, in 0.001…180°.
- `language` (`en`, `ar`, `ja`, `de`, `fr`, `he`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md))
- `length_decimals`
- `logo_accent` (bool, default false: the application bar's logo in shades of the theme's accent instead of the theme's logo colours)
+5 -1
View File
@@ -157,7 +157,7 @@ pub fn router_skills(text: &str) -> BTreeSet<String> {
/// The `name` and `description` of a `---` front matter block.
pub fn front_matter(text: &str) -> Option<(String, String)> {
let rest = text.strip_prefix("---\n")?;
let rest = text.strip_prefix("---\n").or_else(|| text.strip_prefix("---\r\n"))?;
let block = rest.split("\n---").next()?;
let get =
|key: &str| block.lines().find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix(':'))).map(|v| v.trim().trim_matches('"').to_owned());
@@ -405,6 +405,10 @@ mod tests {
]
);
assert_eq!(front_matter("no front matter"), None);
assert_eq!(
front_matter("---\r\nname: solidcraft-a\r\ndescription: Windows checkout\r\n---\r\n"),
Some(("solidcraft-a".into(), "Windows checkout".into()))
);
}
#[test]
+11 -7
View File
@@ -443,7 +443,7 @@ pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String
// Chrome sends a server at most six requests at once: a build with more files has the rest
// wait for the first ones. v6 is deployed once all of v5's are asked for (any asked later
// would get v6's file, which rightly ends v5's download, but that isn't this step).
server.wait_asked_slowly(asked + only_in(&v5, &v6)?)?;
server.wait_asked_slowly(asked + file_count(&v5)?)?;
server.set(Mode::Serve(v6));
// v5's files are still held while v6 launches; they come once v6 has started.
let t = launch(&mut cdp, "v6")?;
@@ -520,16 +520,16 @@ const STATE_JS: &str = "(async () => {
registered: (await navigator.serviceWorker.getRegistrations()).filter((r) => r.scope === scope).length };
})()";
/// The worker's cache for a build of the copy at `path` (sw.js's CACHE_PREFIX + version).
/// How many files `build` keeps that `other` doesn't have.
fn only_in(build: &Path, other: &Path) -> Result<usize, String> {
/// Every file this build downloads, including names shared with the next build.
fn file_count(build: &Path) -> Result<usize, String> {
let path = build.join("build.json");
let json: Value =
std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).ok_or_else(|| format!("{}: not readable", path.display()))?;
let files = json["files"].as_array().into_iter().flatten().filter_map(|f| f["file"].as_str());
Ok(files.filter(|f| !other.join(f).exists()).count())
Ok(files.count())
}
/// The worker's cache for a build of the copy at `path` (sw.js's CACHE_PREFIX + version).
fn cache_name(path: &str, version: &str) -> String {
format!("solidcraft-app {path} {version}")
}
@@ -810,9 +810,13 @@ fn answer(mut stream: TcpStream, shared: &Mutex<Mode>, seen: &Counters, elsewher
Mode::Flaky(dir) => (dir, true, false),
Mode::SlowCheck(dir) => (dir, false, true),
Mode::SlowFiles(dir, next) => {
if !small && file.is_some_and(|f| !next.join(f).exists()) {
if !small && file.is_some() {
// Count shared names too: switching builds before their requests arrive would
// serve the next build's bytes and correctly cancel this download's hash check.
seen.slow.fetch_add(1, Ordering::SeqCst);
seen.gate.pass();
if file.is_some_and(|f| !next.join(f).exists()) {
seen.gate.pass();
}
}
(dir, false, false)
}