mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Motion interference: a collision is deepest where the parts share the most volume
motion.interference picked a collision's deepest step by the depth sampled on the contact meshes (vertices, edge midpoints, triangle centres), and worked out the exact shared volume only there. That depth depends on where the mesh puts its points: since planes mesh as their boundary's triangles, the arm turning into a block (rig case assemblies/cov-motion-interference-arm-into-the-block) sampled 4.64 mm at 60 degrees and 2.0 mm at 90, and the check answered step 6 with 536 mm3. The exact depth is 5 mm at every step from 60 to 90 degrees (both slabs are 10 mm thick), so depth cannot tell those steps apart. The exact shared volume is now worked out at each sample a pair overlaps, and the deepest step is the first with the most (a tie keeps the earlier sample). The arm and block share 536, 807, 952 and 1000 mm3 at 60 to 90 degrees, so it is step 9 with 1000 mm3, at 10 samples or 19. depth_mm is reported as before and documented as an estimate from the contact meshes. volume_mm3 is null, and the step falls back to the sampled depth, when the volume was not worked out at every sample (more than 16 pairs, or past the boolean budget, raised from 64 to 256 now that it covers every sample). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
//! answers the pairs of parts that pass through each other along the way.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::joints;
|
||||
@@ -31,8 +30,10 @@ pub const INTERFERENCE_DOC: &str = "study: id|name; occurrences?: [ids or names]
|
||||
default every occurrence), with against?: [ids or names] (then each of `occurrences` is checked only against these, such \
|
||||
as an arm against the parts it must miss), or contact_sets?: true (the pairs of the design's contact sets); samples? \
|
||||
(2…1000, default the study's steps + 1) → collisions: [{a, b, first_step, last_step, steps: [[from, to]…] (the step ranges where they overlap), \
|
||||
depth_mm (how far into each other, the most at deepest_step), volume_mm3 (their overlap there; null when it was not \
|
||||
cheap to work out)}], clear, \
|
||||
volume_mm3 (the most volume they share at a sample, worked out exactly from the bodies; null when it was not cheap to \
|
||||
work out at every sample they overlap), deepest_step (the first sample sharing that volume; with volume_mm3 null, the \
|
||||
one of the greatest depth_mm), depth_mm (how far into each other at most: an estimate from points of the bodies' \
|
||||
contact meshes, not exact)}], clear, \
|
||||
pairs (checked), samples (taken), truncated (the work budget ran out; checked_to: the last step checked), stopped_at, \
|
||||
stopped_by_loop. The study plays as motion.play plays it but contact sets do not stop it, on a copy (the design is not \
|
||||
changed). Parts are tested at the samples only: one that passes wholly through another between two samples is missed";
|
||||
@@ -43,9 +44,10 @@ const MAX_SAMPLES: usize = 1000;
|
||||
const MAX_SERIES: usize = 256;
|
||||
/// Most collision work over a whole study (see `ContactGeometry::hits`).
|
||||
const MAX_WORK: usize = 500_000_000;
|
||||
/// Most colliding pairs whose overlap volume is worked out, and most body booleans that takes.
|
||||
/// Most colliding pairs whose overlap volume is worked out, and most body booleans that takes
|
||||
/// over the whole study (one per pair of overlapping bodies at each sample a pair collides).
|
||||
const MAX_VOLUMES: usize = 16;
|
||||
const MAX_BOOLEANS: usize = 64;
|
||||
const MAX_BOOLEANS: usize = 256;
|
||||
|
||||
/// How many samples `p` asks for (`min`…`MAX_SAMPLES`, default the study's steps + 1).
|
||||
fn samples(p: &Value, m: &MotionStudy, min: usize, cmd: &str) -> Result<usize> {
|
||||
@@ -250,10 +252,29 @@ struct Collision {
|
||||
names: (String, String),
|
||||
/// The samples where they overlap, in order.
|
||||
samples: Vec<usize>,
|
||||
/// The most sampled mesh depth, and the first sample reaching it.
|
||||
depth: f64,
|
||||
deepest: usize,
|
||||
/// The design at the deepest sample (its placements).
|
||||
at_deepest: Arc<Document>,
|
||||
/// The most exact shared volume and the first sample sharing it; `None` once a sample's
|
||||
/// volume was not worked out (the pair got no volume slot, or the booleans ran out).
|
||||
most_volume: Option<(f64, usize)>,
|
||||
}
|
||||
|
||||
impl Collision {
|
||||
/// Takes the exact shared volume at sample `k` (`None`: not worked out). A volume larger by
|
||||
/// no more than the booleans' rounding is a tie, which keeps the earlier sample.
|
||||
fn take_volume(&mut self, k: usize, volume: Option<f64>) {
|
||||
self.most_volume = match (self.most_volume, volume) {
|
||||
(Some((best, at)), Some(v)) => Some(if v > best + 1e-9 * best.max(1.0) { (v, k) } else { (best, at) }),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
/// The sample the collision is deepest at: where they share the most volume, else (no
|
||||
/// volume) where the sampled depth is greatest.
|
||||
fn deepest_sample(&self) -> usize {
|
||||
self.most_volume.map_or(self.deepest, |(_, at)| at)
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume the parts under occurrences `a` and `b` share, placed as in `doc` (`None`: not
|
||||
@@ -358,12 +379,14 @@ pub(super) fn checked_pairs(s: &Session, p: &Value, cmd: &str) -> Result<Contact
|
||||
}
|
||||
|
||||
pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let s: &Session = s;
|
||||
let cmd = "motion.interference";
|
||||
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
|
||||
let n = samples(p, &m, 2, cmd)?;
|
||||
let geometry = checked_pairs(s, p, cmd)?;
|
||||
let mut found: BTreeMap<(u64, u64), Collision> = BTreeMap::new();
|
||||
let (mut work, mut checked, mut truncated) = (0usize, 0usize, false);
|
||||
let mut booleans = MAX_BOOLEANS;
|
||||
let stop = play(s, &m, n, false, |t, k| {
|
||||
// Placing the meshes is work too, also where nothing comes near.
|
||||
work = work.saturating_add(geometry.vertex_count());
|
||||
@@ -383,18 +406,24 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for ((a, an), (b, bn), depth) in hits {
|
||||
// Each pair once, the older occurrence (the lower id) first.
|
||||
let ((a, an), (b, bn)) = if a <= b { ((a, an), (b, bn)) } else { ((b, bn), (a, an)) };
|
||||
// The first MAX_VOLUMES pairs found have their shared volume worked out.
|
||||
let slots = found.len() < MAX_VOLUMES;
|
||||
let c = found.entry((a, b)).or_insert_with(|| Collision {
|
||||
names: (an, bn),
|
||||
samples: Vec::new(),
|
||||
depth: 0.0,
|
||||
deepest: k,
|
||||
at_deepest: t.doc.clone(),
|
||||
most_volume: slots.then_some((0.0, k)),
|
||||
});
|
||||
if c.samples.last() != Some(&k) {
|
||||
c.samples.push(k);
|
||||
if c.samples.last() == Some(&k) {
|
||||
continue;
|
||||
}
|
||||
c.samples.push(k);
|
||||
if depth > c.depth {
|
||||
(c.depth, c.deepest, c.at_deepest) = (depth, k, t.doc.clone());
|
||||
(c.depth, c.deepest) = (depth, k);
|
||||
}
|
||||
if c.most_volume.is_some() {
|
||||
c.take_volume(k, shared_volume(s, &t.doc, a, b, &mut booleans));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -405,16 +434,14 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect();
|
||||
list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX));
|
||||
let mut booleans = MAX_BOOLEANS;
|
||||
let collisions: Vec<Value> = list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ((a, b), c))| {
|
||||
let volume = if i < MAX_VOLUMES { shared_volume(s, &c.at_deepest, *a, *b, &mut booleans) } else { None };
|
||||
.map(|(_, c)| {
|
||||
let volume = c.most_volume.map(|(v, _)| v);
|
||||
let first = c.samples.first().map(|k| step_of(&m, *k, n));
|
||||
let last = c.samples.last().map(|k| step_of(&m, *k, n));
|
||||
json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n),
|
||||
"depth_mm": c.depth, "deepest_step": step_of(&m, c.deepest, n), "volume_mm3": volume})
|
||||
"depth_mm": c.depth, "deepest_step": step_of(&m, c.deepest_sample(), n), "volume_mm3": volume})
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"study": m.name, "steps": m.steps, "clear": collisions.is_empty(), "collisions": collisions,
|
||||
|
||||
@@ -147,10 +147,9 @@ fn interference_names_the_step_two_parts_start_to_collide() {
|
||||
assert_eq!((c["a"].as_str(), c["b"].as_str()), (Some("A:1"), Some("B:1")), "{r}");
|
||||
assert_eq!((c["first_step"].as_f64(), c["last_step"].as_f64()), (Some(11.0), Some(15.0)), "{r}");
|
||||
assert_eq!(c["steps"], json!([[11.0, 15.0]]), "{r}");
|
||||
let deepest = c["deepest_step"].as_f64().unwrap_or(f64::NAN);
|
||||
assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && (11.0..=15.0).contains(&deepest), "{r}");
|
||||
let volume = c["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((volume - 100.0 * (deepest - 10.0)).abs() < 1e-6, "{r}");
|
||||
// Deepest where they share the most: 5 mm of the 10 x 10 face at the end.
|
||||
assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && c["deepest_step"] == 15.0, "{r}");
|
||||
assert!((c["volume_mm3"].as_f64().unwrap_or(f64::NAN) - 500.0).abs() < 1e-6, "{r}");
|
||||
}
|
||||
// B against a far block C only: B's pass through A is not asked about.
|
||||
run(&mut s, "component.create", json!({"name": "C"}));
|
||||
@@ -167,6 +166,39 @@ fn interference_names_the_step_two_parts_start_to_collide() {
|
||||
assert!(r["clear"] == true && r["collisions"] == json!([]), "{r}");
|
||||
}
|
||||
|
||||
/// An arm (40 x 10 x 10 from (0, -5, 0)) turns on a hinge at the origin from 0 to 90 degrees in
|
||||
/// 9 steps into a grounded block (50 x 10 x 10 from (-10, 30, 0)). Its corner (40, 5) reaches
|
||||
/// y = 40 sin θ + 5 cos θ, past 30 from 40.97 degrees, so it overlaps from step 5. Every point of
|
||||
/// the slab is within 5 mm of z = 0 or z = 10, so from 60 degrees on (where the arm's side
|
||||
/// crosses y = 35) the depth is 5 mm at every step: only the shared volume, which grows to the
|
||||
/// arm's 10 x 10 x 10 end inside the block at 90 degrees, tells the deepest step. Sampled twice
|
||||
/// as densely (5 degrees a sample), the overlap is seen from step 4.5 (45 degrees) and is still
|
||||
/// deepest at the end.
|
||||
#[test]
|
||||
fn interference_is_deepest_where_the_parts_share_the_most_volume() {
|
||||
let mut s = Session::default();
|
||||
for (name, size, corner) in [("Base", [20, 20, 10], [-10, -10, -10]), ("Arm", [40, 10, 10], [0, -5, 0]), ("Stop", [50, 10, 10], [-10, 30, 0])] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
for name in ["Base", "Stop"] {
|
||||
let o = occ(&s, name);
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
}
|
||||
let end = |o: &str| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]});
|
||||
run(&mut s, "joint.create", json!({"type": "revolute", "name": "Hinge", "flip": true, "a": end("Base"), "b": end("Arm"), "quiet": true}));
|
||||
run(&mut s, "motion.study", json!({"name": "Open", "steps": 9, "keys": [{"joint": "Hinge", "points": [[0, 0], [9, 90]]}]}));
|
||||
for (samples, first) in [(10, 5.0), (19, 4.5)] {
|
||||
let r = run(&mut s, "motion.interference", json!({"study": "Open", "samples": samples, "occurrences": ["Arm"], "against": ["Stop"]}));
|
||||
let c = &r["collisions"][0];
|
||||
assert!(r["clear"] == false && c["first_step"] == first && c["last_step"] == 9.0 && c["deepest_step"] == 9.0, "{samples}: {r}");
|
||||
assert!((c["volume_mm3"].as_f64().unwrap_or(f64::NAN) - 1000.0).abs() < 1e-6, "{samples}: {r}");
|
||||
assert!(c["depth_mm"].as_f64().is_some_and(|d| d > 0.0 && d <= 5.0 + 1e-9), "{samples}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A number names a study by its id first, text by its name first: a study may be named like
|
||||
/// another's id.
|
||||
#[test]
|
||||
|
||||
@@ -322,9 +322,11 @@ accelerations are finite differences between samples, central inside and one-sid
|
||||
so they are as smooth as the samples are dense (up to 1000, one per step by default). Angles are
|
||||
unwrapped, so a joint that turns on past half a turn keeps counting.
|
||||
`motion.interference {study, occurrences?, against?, contact_sets?, samples?}` answers each
|
||||
colliding pair with its `first_step`, `last_step`, the `steps` ranges where they overlap, how
|
||||
far into each other (`depth_mm`, the most at `deepest_step`) and their shared `volume_mm3`
|
||||
there (null when it is not cheap to work out). It plays the study as `motion.play` does, but
|
||||
colliding pair with its `first_step`, `last_step`, the `steps` ranges where they overlap, the
|
||||
most volume they share at a sample (`volume_mm3`, worked out exactly from the bodies; null when
|
||||
it is not cheap to work out at every sample they overlap), the first sample sharing that much
|
||||
(`deepest_step`) and how far into each other they reach (`depth_mm`, an estimate from points of
|
||||
the bodies' contact meshes). It plays the study as `motion.play` does, but
|
||||
contact sets don't stop it, and parts are tested at the samples only: a part that passes right
|
||||
through another between two samples is missed. Parts are told apart by occurrence, as contact
|
||||
sets tell them apart: an occurrence inside a sub-assembly placed twice is one part, so its two
|
||||
|
||||
Reference in New Issue
Block a user