mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d01515911 | |||
| 8ffb97d541 | |||
| 8f13e948a9 | |||
| 59a85a1646 | |||
| 4890cebd36 | |||
| 27d5a264f3 | |||
| 3b82c40ff7 | |||
| a05b47339f | |||
| b53fa7a569 | |||
| 4579e1ad50 | |||
| bc63b3670b | |||
| dfb240a982 | |||
| a22d42ad98 | |||
| 7c03f57fd9 | |||
| 91a08fc63a | |||
| 193a13d7fc | |||
| 2e8e68c64d | |||
| 11d408d8c2 | |||
| 1b4547f2b3 | |||
| ae4f93a7cf | |||
| 59b78db068 | |||
| 9210343c9d | |||
| cce1c9dcf5 | |||
| 11bb98683f | |||
| 55297e5b30 |
@@ -448,8 +448,8 @@ jobs:
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim, triage and PR-check bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
|
||||
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
|
||||
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
|
||||
# in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: write # to list a PR's changed paths and add its label
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
paused:
|
||||
name: Kernel paused bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/kernel-paused
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const paused = require('./scripts/kernel-paused/kernel-paused.js');
|
||||
const done = context.eventName === 'discussion'
|
||||
? await paused.handleDiscussion({ github, context })
|
||||
: await paused.handleOpened({ github, context });
|
||||
core.info(done);
|
||||
+8
-25
@@ -17,28 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work goes in solidcore/
|
||||
## Kernel work is paused
|
||||
|
||||
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
|
||||
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
|
||||
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
|
||||
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
|
||||
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
|
||||
|
||||
A kernel-only pull request targets the `kernel` branch. A change that touches the kernel and the
|
||||
app goes to `main` as one pull request, with a code owner approving the `solidcore/` part.
|
||||
Crashes and data loss are still taken first.
|
||||
|
||||
### Updating a PR that was moved from solidcore
|
||||
|
||||
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
|
||||
contributors without write access can't push to them. To update one:
|
||||
|
||||
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
|
||||
`git switch moved/solidcore-N`.
|
||||
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
|
||||
3. Open a PR against `kernel` that says "Replaces #M" (M is the moved PR's number). The moved one
|
||||
gets closed.
|
||||
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
|
||||
change `crates/kernel/`, `crates/geom/` or the vendored kernel in `solidcore/`, or issues and discussions about kernel
|
||||
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
|
||||
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
|
||||
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
|
||||
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
|
||||
including the dialogs and tools for these features.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -442,10 +429,6 @@ owning one area, with a human setting direction and reporting bugs. Their workin
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
|
||||
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
|
||||
comments only when they're about crashes, wrong results, data loss or security.
|
||||
|
||||
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
|
||||
fails when a lower-numbered open PR by someone else already closes the same issue (the label
|
||||
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
|
||||
|
||||
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
|
||||
}
|
||||
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
|
||||
pub(crate) const AXIS_RADIUS: f32 = 15.0;
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
|
||||
}
|
||||
|
||||
/// The same compass artwork at a uniform scale, including its lettering and strokes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d[0] != 0.0 {
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d.x != 0.0 {
|
||||
t.axis_x
|
||||
} else if d[1] != 0.0 {
|
||||
} else if d.y != 0.0 {
|
||||
t.axis_y
|
||||
} else {
|
||||
t.axis_z
|
||||
|
||||
+8
-144
@@ -1163,9 +1163,6 @@ pub struct Model {
|
||||
placement_local: bool,
|
||||
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
|
||||
held: Option<HeldSketch>,
|
||||
/// The [`placement_digest`] each [`pose_only`] feature was last evaluated at, by feature id.
|
||||
/// Their fingerprints leave it out, so a pose change doesn't reach the features after them.
|
||||
poses: std::collections::HashMap<u64, u64>,
|
||||
}
|
||||
|
||||
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
|
||||
@@ -1482,10 +1479,7 @@ fn chain_fingerprint(
|
||||
steps: &ImportDigests,
|
||||
st: Option<&ModelState>,
|
||||
) -> u64 {
|
||||
// A pose-only feature's placement is checked on its own ([`Model::poses`]), so moving the
|
||||
// parts it reads doesn't change the fingerprint of every feature after it (#12139).
|
||||
let placed = if pose_only(f) { 0 } else { placement_digest(doc, f, st) };
|
||||
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placed;
|
||||
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -1799,70 +1793,6 @@ fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>]
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Whether only the bodies `f` makes follow where parts are placed: a mirror of placed bodies
|
||||
/// into new bodies. A feature after it reads its copies only by naming them, so a pose change
|
||||
/// need not evaluate features that don't (#12139).
|
||||
fn pose_only(f: &Feature) -> bool {
|
||||
matches!(&f.kind, FeatureKind::Mirror { features, bodies, placements, combine: false, .. }
|
||||
if features.is_empty() && !bodies.is_empty() && !placements.is_empty())
|
||||
}
|
||||
|
||||
/// The new bodies of pose-only `f`, evaluated again (to `state`) for its pose alone: the result
|
||||
/// `old` was on the same definitions and made bodies of the same names, and nothing besides.
|
||||
fn pose_moved(old: Option<&FeatureResult>, f: &Feature, fp: u64, state: &ModelState) -> Option<Vec<ModelBody>> {
|
||||
let old = old.filter(|o| o.id == f.id && o.fingerprint == fp && !o.stale && !o.skipped && !o.unkept && o.error.is_none())?;
|
||||
let made = |st: &ModelState| st.bodies.iter().filter(|b| b.feature == f.id).cloned().collect::<Vec<_>>();
|
||||
let (before, after) = (made(&old.state), made(state));
|
||||
let names = |bodies: &[ModelBody]| bodies.iter().map(|b| b.name.clone()).collect::<std::collections::BTreeSet<_>>();
|
||||
let same = names(&before) == names(&after)
|
||||
&& old.state.bodies.len() == state.bodies.len()
|
||||
&& old.state.threads.len() == state.threads.len()
|
||||
&& old.state.sheets.len() == state.sheets.len()
|
||||
&& old.state.construct.len() == state.construct.len();
|
||||
same.then_some(after)
|
||||
}
|
||||
|
||||
/// `old` (a result after the moved pose-only features) with their new bodies in place of theirs.
|
||||
fn with_moved(old: &Arc<ModelState>, moved: &[(u64, Vec<ModelBody>)]) -> Arc<ModelState> {
|
||||
let mut st = (**old).clone();
|
||||
for b in &mut st.bodies {
|
||||
if let Some(new) = moved.iter().filter(|(id, _)| *id == b.feature).flat_map(|(_, bodies)| bodies).find(|n| n.name == b.name) {
|
||||
*b = new.clone();
|
||||
}
|
||||
}
|
||||
Arc::new(st)
|
||||
}
|
||||
|
||||
/// Whether `f` (starting from `st`) reads none of the bodies of `moved`: it reads no bodies, or
|
||||
/// only bodies it names or would cut or join, none of them those. Anything else (a picked face,
|
||||
/// a projection, To Object, ...) is taken as reading them.
|
||||
fn reads_none_of(f: &Feature, st: &ModelState, moved: &[(u64, Vec<ModelBody>)], components: &std::collections::HashMap<u64, u64>) -> bool {
|
||||
if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let reads: Vec<String> = match &f.kind {
|
||||
_ if placement_reuse::local_geometry(f, components) => Vec::new(),
|
||||
FeatureKind::Sketch { plane, sketch } if sketch.links.is_empty() && sketch.wires.is_empty() && placement_reuse::local_plane(plane, 0) => {
|
||||
Vec::new()
|
||||
}
|
||||
FeatureKind::Mirror { features, bodies, plane, .. } if features.is_empty() && placement_reuse::local_plane(plane, 0) => {
|
||||
bodies.iter().map(|b| b.0.clone()).collect()
|
||||
}
|
||||
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => return false,
|
||||
FeatureKind::Revolve { to: Some(_), .. } | FeatureKind::Revolve { axis: AxisRef::Model { .. }, .. } => return false,
|
||||
kind if drawn_tools_change_bodies(kind) => {
|
||||
let mut reads = PlacementReads::default();
|
||||
reads.drawn_targets(Some(st), f);
|
||||
if reads.all {
|
||||
return false;
|
||||
}
|
||||
reads.bodies.into_iter().collect()
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
!moved.iter().flat_map(|(_, bodies)| bodies).any(|b| reads.contains(&b.name))
|
||||
}
|
||||
|
||||
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
|
||||
// No shortcut for a design without placements: its first component (placed where the
|
||||
// design is) must not change what its features read, or creating it evaluates them all.
|
||||
@@ -2263,11 +2193,10 @@ impl Model {
|
||||
let mut state = self.empty.clone();
|
||||
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
|
||||
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
|
||||
let posed = !pose_only(f) || self.poses.get(&f.id) == Some(&placement_digest(doc, f, Some(&state)));
|
||||
if !r.skipped {
|
||||
state = r.state.clone();
|
||||
}
|
||||
r.id == f.id && r.fingerprint == prev && posed
|
||||
r.id == f.id && r.fingerprint == prev
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2335,10 +2264,6 @@ impl Model {
|
||||
feature_component.entry(f.id).or_insert(f.component);
|
||||
}
|
||||
let watched = crate::progress::begin(doc.features.len());
|
||||
let mut poses = std::collections::HashMap::new();
|
||||
// Pose-only features evaluated again for a pose change alone: their new bodies, which
|
||||
// replace the old ones in the results reused after them (#12139).
|
||||
let mut moved: Vec<(u64, Vec<ModelBody>)> = Vec::new();
|
||||
// The features that failed so far in this pass, by id: their names and errors (#9075).
|
||||
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
@@ -2367,12 +2292,7 @@ impl Model {
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
|
||||
prev_fp = fp;
|
||||
let pose = pose_only(f).then(|| placement_digest(doc, f, Some(&state)));
|
||||
if let Some(pose) = pose {
|
||||
poses.insert(f.id, pose);
|
||||
}
|
||||
let posed = pose.is_none_or(|pose| self.poses.get(&f.id) == Some(&pose));
|
||||
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale && posed;
|
||||
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
|
||||
// A state the saved model did not keep is evaluated again from the one before it; the
|
||||
// results after it that the model did keep still stand (#3220).
|
||||
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
|
||||
@@ -2383,28 +2303,14 @@ impl Model {
|
||||
// has a result only while those picks are what they were.
|
||||
if reuse
|
||||
&& !unkept
|
||||
&& let Some(old) = self
|
||||
.results
|
||||
.get(i)
|
||||
.filter(current)
|
||||
.filter(|old| moved.is_empty() || old.skipped || reads_none_of(f, &state, &moved, &feature_component))
|
||||
.filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
|
||||
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
|
||||
{
|
||||
if moved.is_empty() {
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
}
|
||||
out.push(old.clone());
|
||||
} else {
|
||||
if !old.skipped {
|
||||
state = with_moved(&old.state, &moved);
|
||||
}
|
||||
out.push(FeatureResult { state: state.clone(), ..old.clone() });
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
}
|
||||
out.push(old.clone());
|
||||
continue;
|
||||
}
|
||||
// Reuse goes on past a pose-only feature evaluated again for its pose alone.
|
||||
let reusing = reuse && !unkept && pose.is_some();
|
||||
reuse = unkept && !cancelled;
|
||||
if f.suppressed || rolled_back || cancelled {
|
||||
out.push(FeatureResult {
|
||||
@@ -2480,14 +2386,6 @@ impl Model {
|
||||
}
|
||||
// A kept result after a failed one would not be this evaluation's.
|
||||
reuse &= c.error.is_none();
|
||||
if reusing
|
||||
&& c.error.is_none()
|
||||
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
|
||||
{
|
||||
moved.retain(|(id, _)| *id != f.id);
|
||||
moved.push((f.id, bodies));
|
||||
reuse = true;
|
||||
}
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -2509,9 +2407,8 @@ impl Model {
|
||||
// (the same chain of definitions from the start). Changed inputs never reuse it.
|
||||
if let Some(r) = rolled
|
||||
.as_ref()
|
||||
.filter(|_| moved.is_empty())
|
||||
.filter(current)
|
||||
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
|
||||
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
|
||||
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
|
||||
{
|
||||
restored += 1;
|
||||
@@ -2652,14 +2549,6 @@ impl Model {
|
||||
}
|
||||
}
|
||||
reuse &= error.is_none();
|
||||
if reusing
|
||||
&& error.is_none()
|
||||
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
|
||||
{
|
||||
moved.retain(|(id, _)| *id != f.id);
|
||||
moved.push((f.id, bodies));
|
||||
reuse = true;
|
||||
}
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -2677,7 +2566,6 @@ impl Model {
|
||||
});
|
||||
}
|
||||
self.placement_local = self.placement_local && out.iter().all(|r| !r.skipped && !r.stale && !r.unkept && r.error.is_none());
|
||||
self.poses = poses;
|
||||
self.results = out;
|
||||
self.last_recomputed = recomputed;
|
||||
self.recomputed += recomputed;
|
||||
@@ -7071,30 +6959,6 @@ fn replay(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A thread is cut again on the copy of its face. A copy keeps the thread's hand: the
|
||||
// mirror of a right-hand M8 hole is a right-hand M8 hole, not a left-hand one.
|
||||
if matches!(src.kind, FeatureKind::Thread { .. }) {
|
||||
for m in mats {
|
||||
let mut copy = (*src).clone();
|
||||
copy.id = f.id;
|
||||
copy.face_names.clear();
|
||||
if let FeatureKind::Thread { face, from, up_to, .. } = &mut copy.kind {
|
||||
face.0 = crate::apply_point(m, face.0);
|
||||
if let Some(q) = from {
|
||||
q.0 = crate::apply_point(m, q.0);
|
||||
}
|
||||
match up_to {
|
||||
Some(crate::ThreadLimit::Face(q)) => q.0 = crate::apply_point(m, q.0),
|
||||
Some(crate::ThreadLimit::Plane(_)) => {
|
||||
return Err(DocError::Invalid(format!("{} ends at a plane, so it cannot be patterned or mirrored yet", src.name)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
thread::eval(doc, vals, ©, st, warning)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (op, targets) = feature_op(src);
|
||||
// A primitive's or a hole's targets are its participants (#1108): the copies change
|
||||
// those bodies only, like the feature itself (#2755).
|
||||
|
||||
@@ -917,10 +917,7 @@ impl Model {
|
||||
}
|
||||
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
|
||||
let unkept = model.results.iter().filter(|r| r.unkept).count();
|
||||
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be from
|
||||
// another pose than `doc`'s: evaluated again, with what reads none of it kept (#12139).
|
||||
let posed = doc.features.iter().zip(&model.results).any(|(f, r)| !r.skipped && pose_only(f));
|
||||
if unkept > 0 || posed {
|
||||
if unkept > 0 {
|
||||
// The features whose state the file did not keep, each evaluated again from the state
|
||||
// before it: nothing else in the model is missing then.
|
||||
model.evaluate(doc);
|
||||
@@ -936,12 +933,6 @@ impl Model {
|
||||
let mut before = Arc::new(ModelState::default());
|
||||
if let Ok(mut cache) = model.cache.lock() {
|
||||
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
|
||||
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be
|
||||
// from another pose than `doc`'s: it is no result for `doc`'s key (#12139).
|
||||
if pose_only(f) {
|
||||
before = r.state.clone();
|
||||
continue;
|
||||
}
|
||||
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
cache.put(
|
||||
cache_key(f, r.fingerprint, local, ctx, &before),
|
||||
|
||||
@@ -55,7 +55,7 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
|
||||
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
|
||||
if depth > crate::MAX_PLACEMENT_DEPTH {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::schema::features::{
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
SphereParams, TorusParams,
|
||||
};
|
||||
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -37,8 +36,6 @@ const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::n
|
||||
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
||||
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
|
||||
const SPLIT_FACE_CONTRACT: TypedContract<SplitFaceParams> = TypedContract::new();
|
||||
const SPLIT_BODY_CONTRACT: TypedContract<SplitBodyParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||
@@ -176,19 +173,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&PLANE_AT_ANGLE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; direction_only?: bool (default false: turn about the axis as a line; true: project the axis direction onto base and turn about the line through pivot, or the base origin); pivot?: point ref (only with direction_only; vertex, sketch point, construction point); name?"),
|
||||
CommandSpec::new("solid.split_face", "Split Face", crate::checked!("solid.split_face", split_face))
|
||||
CommandSpec::new("solid.split_face", "Split Face", split_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("split_face")
|
||||
.contract(&SPLIT_FACE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
|
||||
),
|
||||
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
|
||||
CommandSpec::new("solid.split_body", "Split Body", split_body)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("split_body")
|
||||
.contract(&SPLIT_BODY_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
|
||||
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
|
||||
.at("SOLID", "MODIFY")
|
||||
|
||||
@@ -63,11 +63,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
|
||||
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
|
||||
CommandSpec::new("file.export", "Export", crate::checked!("file.export", export))
|
||||
CommandSpec::new("file.export", "Export", export)
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
// One command per format, so each File › Export item can have a key of its own; started
|
||||
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
|
||||
@@ -130,7 +128,6 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportParams> = crate::schema::TypedContract::new();
|
||||
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
|
||||
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
|
||||
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
|
||||
|
||||
@@ -1239,31 +1239,3 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
|
||||
let far = s.world_state().unwrap().bodies.iter().map(|b| b.mesh().bounds().max.x).fold(f64::MIN, f64::max);
|
||||
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
|
||||
}
|
||||
|
||||
/// #13278: a mirror of a Thread repeats it on the mirrored hole, whether the hole was mirrored
|
||||
/// earlier or is mirrored together with the thread.
|
||||
#[test]
|
||||
fn mirror_of_a_thread_threads_the_mirrored_hole() {
|
||||
let plane = json!({"origin": [20, 0, 0], "normal": [1, 0, 0]});
|
||||
for (together, modeled) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": 6.8, "depth": 10, "name": "Hole1"}));
|
||||
if !together {
|
||||
let r = run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": plane}));
|
||||
assert!(r["error"].is_null(), "{r}");
|
||||
}
|
||||
run(&mut s, "solid.thread", json!({"face": [13.4, 15, 5], "designation": "M8", "modeled": modeled, "name": "Thread1"}));
|
||||
let features = if together { json!(["Hole1", "Thread1"]) } else { json!(["Thread1"]) };
|
||||
let r = run(&mut s, "solid.mirror", json!({"features": features, "plane": plane}));
|
||||
assert!(r["error"].is_null(), "together {together}: {r}");
|
||||
let state = s.world_state().unwrap();
|
||||
if modeled {
|
||||
assert_eq!(state.bodies.len(), 1, "{r}");
|
||||
let threaded = volume(&mut s);
|
||||
assert!(threaded < 40.0 * 30.0 * 10.0 - 2.0 * std::f64::consts::PI * 3.4 * 3.4 * 10.0 - 1.0, "both holes are threaded: {threaded}");
|
||||
} else {
|
||||
assert_eq!(state.threads.len(), 2, "together {together}: the source and its mirror");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::schema::sketch::{
|
||||
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
|
||||
};
|
||||
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
|
||||
use crate::schema::sketch_dimensioned::{DrawDimensionedParams, DrawDimensionedResult};
|
||||
use crate::schema::sketch_session::{
|
||||
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
|
||||
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
|
||||
@@ -64,7 +63,6 @@ const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContrac
|
||||
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
|
||||
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
|
||||
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
|
||||
const DRAW_DIMENSIONED_CONTRACT: TypedContract<DrawDimensionedParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
|
||||
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
|
||||
@@ -125,10 +123,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&LINE_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", crate::checked!("sketch.draw.dimensioned", draw_dimensioned)).icon("dimension")
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
|
||||
.enabled(in_sketch)
|
||||
.contract(&DRAW_DIMENSIONED_CONTRACT)
|
||||
.output(output_schema_for::<DrawDimensionedResult>)
|
||||
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
|
||||
.at("SKETCH", "CREATE")
|
||||
|
||||
@@ -17,7 +17,6 @@ use solidcraft_doc::expr::{Kind, definition};
|
||||
use crate::{
|
||||
Result, Session,
|
||||
params::{bad, bool_, expr, str_},
|
||||
schema::sketch_dimensioned::DrawDimensionedResult,
|
||||
};
|
||||
|
||||
/// The dimension roles of a sketch creator, in the order `as_drawn` adds them, or None when
|
||||
@@ -136,13 +135,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let out = scratch.execute(create, ¶ms)?;
|
||||
let mut out = scratch.execute(create, ¶ms)?;
|
||||
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let curve = |index: usize| -> Result<Value> {
|
||||
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
|
||||
};
|
||||
let mut added = serde_json::Map::new();
|
||||
let mut defined: Vec<String> = Vec::new();
|
||||
let mut defined = Vec::new();
|
||||
for (role, value) in wanted(roles, dimensions, as_drawn) {
|
||||
let (entities, ty) = match (create, role) {
|
||||
("sketch.line" | "sketch.line.midpoint", "length") => (vec![curve(0)?], "length"),
|
||||
@@ -188,8 +187,8 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
dimension["value"] = value.clone();
|
||||
dimension["if_redundant"] = json!("driven");
|
||||
let result = scratch.execute("sketch.dimension", &dimension)?;
|
||||
if let Some(name) = result.get("defined").and_then(Value::as_str) {
|
||||
defined.push(name.to_string());
|
||||
if let Some(name) = result.get("defined").filter(|v| v.is_string()) {
|
||||
defined.push(name.clone());
|
||||
}
|
||||
added.insert(role.to_string(), result);
|
||||
}
|
||||
@@ -213,8 +212,14 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
|
||||
scratch.undo.clear();
|
||||
s.accept_computed(scratch);
|
||||
let reply = DrawDimensionedResult::new(out, added, defined, locate.then_some(located));
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
out["dimensions"] = Value::Object(added);
|
||||
if !defined.is_empty() {
|
||||
out["defined"] = Value::Array(defined);
|
||||
}
|
||||
if locate {
|
||||
out["located"] = Value::Array(located);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The sketch's degrees of freedom as a command reported them.
|
||||
|
||||
@@ -331,13 +331,7 @@ pub fn run_checked(s: &mut Session, p: &Value, id: &str, contract: &dyn Contract
|
||||
}
|
||||
match result {
|
||||
Ok(Value::Object(mut o)) if level == ParamCheck::Warn && !issues.is_empty() => {
|
||||
// A command that runs another checked command keeps that one's warnings too.
|
||||
let mut warnings = match o.remove("param_warnings") {
|
||||
Some(Value::Array(inner)) => inner,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
warnings.extend(issues.iter().map(|i| json!(i.message)));
|
||||
o.insert("param_warnings".into(), Value::Array(warnings));
|
||||
o.insert("param_warnings".into(), json!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>()));
|
||||
Ok(Value::Object(o))
|
||||
}
|
||||
other => other,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
//! Contracts and result types for the commands in `cmd/file.rs`: so far `file.export` and the
|
||||
//! export command of each format (`file.export_step`, `file.export_stl`, `file.export_3mf`,
|
||||
//! `file.export_obj`, `file.export_urdf`), which take what `file.export` takes for that format,
|
||||
//! without `format`.
|
||||
//! Contracts and result types for the commands in `cmd/file.rs`: so far the export command of
|
||||
//! each format (`file.export_step`, `file.export_stl`, `file.export_3mf`, `file.export_obj`,
|
||||
//! `file.export_urdf`), which take what `file.export` takes for that format, without `format`.
|
||||
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_io::Format;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::StringList;
|
||||
|
||||
/// How closely an exported mesh follows curved faces.
|
||||
@@ -97,137 +93,6 @@ pub struct ExportUrdfParams {
|
||||
pub velocity: Option<f64>,
|
||||
}
|
||||
|
||||
/// Export bodies, or the whole design, to a file. `format` names the format; without it the
|
||||
/// path's extension does. STL, OBJ and 3MF take `quality` (and 3MF `objects`); URDF takes
|
||||
/// `mesh_prefix`, `effort` and `velocity` and exports the whole assembly; design JSON the
|
||||
/// complete document.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = export_required)]
|
||||
pub struct ExportParams {
|
||||
// `path` is optional in the type and required in the schema: left out, the handler refuses it
|
||||
// with its own message.
|
||||
/// The file to write.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, urdf or json, or a file name whose
|
||||
/// extension names it. Default: from the path's extension.
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
/// The bodies to export, by name. Default: every body. Design JSON and URDF export the
|
||||
/// complete design, so they refuse a list.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// How closely the mesh follows curved faces (STL, OBJ and 3MF). Default: standard. Not read
|
||||
/// for URDF.
|
||||
#[serde(default)]
|
||||
pub quality: Option<MeshQualityParam>,
|
||||
/// What each object in a 3MF file holds. Default: body; `component` is refused for the other
|
||||
/// formats. Not read for URDF.
|
||||
#[serde(default)]
|
||||
pub objects: Option<ObjectsParam>,
|
||||
/// URDF only: put before each mesh file name, such as `package://robot/meshes/`. Default: none.
|
||||
#[serde(default)]
|
||||
pub mesh_prefix: Option<String>,
|
||||
/// URDF only: each joint's effort limit, a positive number. Default: 1000.
|
||||
#[serde(default)]
|
||||
pub effort: Option<f64>,
|
||||
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
|
||||
#[serde(default)]
|
||||
pub velocity: Option<f64>,
|
||||
}
|
||||
|
||||
fn export_required(schema: &mut Schema) {
|
||||
super::motion::require(schema, "path");
|
||||
}
|
||||
|
||||
/// The handler's own message for a key, whole.
|
||||
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
|
||||
E::custom(whole(message))
|
||||
}
|
||||
|
||||
/// A key the handler reads as text (`str_`): any other value but `null` is refused, where the
|
||||
/// handler would read it as left out.
|
||||
fn text<E: serde::de::Error>(m: &Map<String, Value>, key: &str) -> Result<Option<String>, E> {
|
||||
match m.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(E::custom(format!("`{key}` must be text"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bodies` as the handler reads it (`params::string_list`), with its messages: `null` is left out.
|
||||
fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringList>, E> {
|
||||
match m.get("bodies") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let p = Value::Object(Map::from_iter([("bodies".to_string(), v.clone())]));
|
||||
match crate::params::string_list("file.export", &p, "bodies") {
|
||||
Ok(list) => Ok(Some(StringList(list))),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => Err(refuse(msg)),
|
||||
Err(e) => Err(refuse(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ExportParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = Map::<String, Value>::deserialize(d)?;
|
||||
let mut out =
|
||||
ExportParams { path: None, format: None, bodies: None, quality: None, objects: None, mesh_prefix: None, effort: None, velocity: None };
|
||||
// The handler reads `path` first and refuses anything but a name it can write to; the
|
||||
// other keys are read only once it has one, and the format is known.
|
||||
out.path = match m.get("path") {
|
||||
None => return Ok(out),
|
||||
Some(Value::String(s)) if !s.trim().is_empty() && s.len() < 4096 => Some(s.clone()),
|
||||
Some(_) => return Err(refuse::<D::Error>("`path` is required")),
|
||||
};
|
||||
out.format = text::<D::Error>(&m, "format")?;
|
||||
let Some(format) = out.format.as_deref().or(out.path.as_deref()).and_then(|n| Format::from_name(n).ok()) else {
|
||||
// The handler refuses a format it does not know before it reads anything else.
|
||||
return Ok(out);
|
||||
};
|
||||
out.bodies = names::<D::Error>(&m)?;
|
||||
if format == Format::Urdf {
|
||||
// URDF reads its own keys; `quality` and `objects` are not read.
|
||||
out.mesh_prefix = text::<D::Error>(&m, "mesh_prefix")?;
|
||||
let positive = |key: &str| -> Result<Option<f64>, D::Error> {
|
||||
match m.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && x.abs() < 1e9 && *x > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| refuse(format!("`{key}` must be a positive number"))),
|
||||
}
|
||||
};
|
||||
out.effort = positive("effort")?;
|
||||
out.velocity = positive("velocity")?;
|
||||
return Ok(out);
|
||||
}
|
||||
// `mesh_prefix`, `effort` and `velocity` are not read for the other formats.
|
||||
out.quality = match m.get("quality") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(match v.as_str().and_then(solidcraft_io::MeshQuality::from_name) {
|
||||
Some(solidcraft_io::MeshQuality::Standard) => MeshQualityParam::Standard,
|
||||
Some(solidcraft_io::MeshQuality::Fine) => MeshQualityParam::Fine,
|
||||
Some(solidcraft_io::MeshQuality::Finest) => MeshQualityParam::Finest,
|
||||
None => return Err(refuse(format!("`quality` must be standard, fine or finest, not {v}"))),
|
||||
}),
|
||||
};
|
||||
out.objects = match m.get("objects") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("body") => Some(ObjectsParam::Body),
|
||||
Some("component") if matches!(format, Format::ThreeMf | Format::ThreeMfTagged) => Some(ObjectsParam::Component),
|
||||
Some("component") => return Err(refuse("`objects: component` is for 3MF: other formats have no parts")),
|
||||
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
|
||||
},
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportResult {
|
||||
@@ -235,8 +100,7 @@ pub struct ExportResult {
|
||||
pub path: String,
|
||||
/// Its size in bytes.
|
||||
pub bytes: usize,
|
||||
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
|
||||
/// ThreeMfTagged or Design).
|
||||
/// The format written: Step, StlBinary, ThreeMf or Obj.
|
||||
pub format: String,
|
||||
/// OBJ only: every file written, the material library first and the mesh file last.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -266,16 +130,6 @@ pub struct UrdfExportResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `file.export` returns: what the export command of the format returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum FileExportResult {
|
||||
/// STL, OBJ, STEP, 3MF or design JSON.
|
||||
File(ExportResult),
|
||||
/// URDF.
|
||||
Urdf(UrdfExportResult),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult};
|
||||
use super::{Export3mfParams, ExportMeshParams, ExportStepParams, ExportUrdfParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
use crate::schema::tests::assert_keys;
|
||||
use crate::schema::{ParamCheck, input_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
@@ -11,7 +11,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<ExportUrdfParams>(), &["effort", "mesh_prefix", "path", "velocity"]);
|
||||
assert_keys(input_schema_for::<ExportParams>(), &["bodies", "effort", "format", "mesh_prefix", "objects", "path", "quality", "velocity"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -37,148 +36,3 @@ fn bad_calls_say_what_is_wrong_and_write_nothing() {
|
||||
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A fresh folder for one test's files.
|
||||
fn folder(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-export-schema-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
dir
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// 10 mm cubes in components A (grounded) and B, so a URDF and a 3MF with parts can be written.
|
||||
fn two_cubes() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, 0, 10], "operation": "new"}));
|
||||
}
|
||||
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let dir = folder("strict");
|
||||
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("file.export", json!({})),
|
||||
("file.export", json!({"path": ""})),
|
||||
("file.export", json!({"path": " "})),
|
||||
("file.export", json!({"path": 5})),
|
||||
("file.export", json!({"path": null})),
|
||||
("file.export", json!({"path": "x".repeat(4096)})),
|
||||
("file.export", json!({"path": at("a.xyz")})),
|
||||
("file.export", json!({"path": at("a.stl"), "format": "xyz"})),
|
||||
("file.export", json!({"path": at("a.xyz"), "quality": "rough"})),
|
||||
("file.export", json!({"path": at("a.stl"), "quality": "rough"})),
|
||||
("file.export", json!({"path": at("a.stl"), "quality": 3})),
|
||||
("file.export", json!({"path": at("a.stl"), "objects": "component"})),
|
||||
("file.export", json!({"path": at("a.3mf"), "objects": "parts"})),
|
||||
("file.export", json!({"path": at("a.stl"), "bodies": [1]})),
|
||||
("file.export", json!({"path": at("a.stl"), "bodies": 1})),
|
||||
("file.export", json!({"path": at("a.json"), "bodies": ["Body1"]})),
|
||||
("file.export", json!({"path": at("a.urdf"), "bodies": ["Body1"]})),
|
||||
("file.export", json!({"path": at("a.urdf"), "effort": -1})),
|
||||
("file.export", json!({"path": at("a.urdf"), "effort": 0})),
|
||||
("file.export", json!({"path": at("a.urdf"), "velocity": "fast"})),
|
||||
("file.export", json!({"path": at("a"), "format": "urdf", "velocity": 1e9})),
|
||||
]);
|
||||
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Each format's keys are read only for that format, as the handler reads them: a key another
|
||||
/// format takes is not checked, and `format` picks the format before the path's extension does.
|
||||
#[test]
|
||||
fn the_export_type_reads_each_key_only_for_its_format() {
|
||||
let read = |p: Value| serde_json::from_value::<ExportParams>(p).map_err(|e| e.to_string());
|
||||
let ok = |p: Value| read(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let p = ok(json!({"path": "a.urdf", "quality": "rough", "objects": 3, "effort": 5}));
|
||||
assert!(p.quality.is_none() && p.objects.is_none() && p.effort == Some(5.0), "{p:?}");
|
||||
let p = ok(json!({"path": "a.stl", "effort": -1, "velocity": "fast", "mesh_prefix": 3, "quality": " Fine "}));
|
||||
assert!(p.effort.is_none() && p.velocity.is_none() && p.mesh_prefix.is_none() && p.quality.is_some(), "{p:?}");
|
||||
assert!(ok(json!({"path": "a.stl", "format": "3mf", "objects": "component"})).objects.is_some());
|
||||
assert!(read(json!({"path": "a.3mf", "format": "stl", "objects": "component"})).is_err());
|
||||
assert!(read(json!({"path": "a", "format": "urdf", "effort": 0})).is_err());
|
||||
// An unknown format, or no path to write to, is the handler's to refuse: nothing else is read.
|
||||
assert!(ok(json!({"path": "a.xyz", "quality": "rough", "effort": -1})).quality.is_none());
|
||||
assert!(ok(json!({"quality": "rough"})).path.is_none());
|
||||
assert!(ok(json!({"path": "a.stl", "bodies": null, "format": null, "quality": null, "objects": null})).bodies.is_none());
|
||||
}
|
||||
|
||||
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
|
||||
/// in `the_export_type_reads_each_key_only_for_its_format`.)
|
||||
#[test]
|
||||
fn the_export_schema_takes_what_the_handler_reads() {
|
||||
let schema = input_schema_for::<ExportParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"path": "a.stl"}), true),
|
||||
(json!({"path": "a.3mf", "format": "3mf-tagged", "bodies": ["A", "B"], "quality": "fine", "objects": "component"}), true),
|
||||
(json!({"path": "robot.urdf", "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2.5}), true),
|
||||
(json!({"path": "a.step", "bodies": "Body1"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"path": "a.stl", "bodies": [1]}), false),
|
||||
(json!({"path": "a.stl", "colour": true}), false),
|
||||
] {
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Real exports of every format in warn: no `param_warnings`, and each reply matches the output
|
||||
/// schema. The same calls go through in strict.
|
||||
#[test]
|
||||
fn export_replies_match_the_output_schema() {
|
||||
let dir = folder("replies");
|
||||
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
let schema = output_schema_for::<FileExportResult>();
|
||||
let schema = schema.as_value();
|
||||
let calls = [
|
||||
json!({"path": at("a.stl")}),
|
||||
json!({"path": at("b.stl"), "format": "stla", "quality": "finest"}),
|
||||
json!({"path": at("c.obj"), "quality": "fine"}),
|
||||
json!({"path": at("d.step")}),
|
||||
json!({"path": at("e.3mf"), "objects": "component"}),
|
||||
json!({"path": at("f.bin"), "format": "3mf-tagged"}),
|
||||
json!({"path": at("g.json")}),
|
||||
json!({"path": at("h.urdf"), "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2}),
|
||||
];
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = two_cubes();
|
||||
s.param_check = level;
|
||||
for p in &calls {
|
||||
let reply = run(&mut s, "file.export", p.clone());
|
||||
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
|
||||
assert!(conforms(schema, schema, &reply), "{p}: {reply} doesn't match {schema}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A misspelt key is reported in warn and refused in strict, before anything is written; a key of
|
||||
/// the wrong type the handler reads as left out is reported too.
|
||||
#[test]
|
||||
fn a_misspelt_or_wrong_typed_export_key_is_reported() {
|
||||
let dir = folder("misspelt");
|
||||
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
let mut s = two_cubes();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let reply = run(&mut s, "file.export", json!({"path": at("a.stl"), "qualty": "fine"}));
|
||||
let warnings = reply["param_warnings"].to_string();
|
||||
assert!(warnings.contains("qualty"), "{reply}");
|
||||
let reply = run(&mut s, "file.export", json!({"path": at("b.stl"), "format": 3}));
|
||||
assert!(reply["param_warnings"].to_string().contains("format"), "{reply}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let e = s.execute("file.export", &json!({"path": at("c.stl"), "qualty": "fine"})).unwrap_err().to_string();
|
||||
assert!(e.contains("qualty"), "{e}");
|
||||
assert!(!dir.join("c.stl").exists(), "nothing written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@ pub mod sketch_clipboard;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_dimension;
|
||||
pub mod sketch_dimensioned;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_import;
|
||||
@@ -124,7 +123,6 @@ pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_session;
|
||||
pub mod sketch_shapes;
|
||||
pub mod split;
|
||||
pub mod surface;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//! Contract and result type for `sketch.draw.dimensioned` in `cmd/sketch_dimensioned.rs` (#13313).
|
||||
//!
|
||||
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
|
||||
//! reads it and fails with the handler's message. `command` and `params` are optional in the
|
||||
//! type and required in the schema, so that the handler's message, not the check's "is required",
|
||||
//! is what a caller sees when they are left out. Which roles `dimensions` may name depends on the
|
||||
//! creator, and whether a value can be evaluated or is in range depends on the design: both stay
|
||||
//! the handler's to check. A call with several problems may get a different one first in strict
|
||||
//! than with the check off (the check goes key by key, the handler in its own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::types::{AngleExpr, LengthExpr};
|
||||
use crate::params;
|
||||
|
||||
/// The sketch creators `sketch.draw.dimensioned` can dimension: those `dimension_roles` lists.
|
||||
pub const CREATORS: &[&str] = &[
|
||||
"sketch.line",
|
||||
"sketch.line.midpoint",
|
||||
"sketch.rectangle.two_point",
|
||||
"sketch.rectangle.center",
|
||||
"sketch.rectangle.three_point",
|
||||
"sketch.rectangle.rounded",
|
||||
"sketch.circle.center",
|
||||
"sketch.circle.two_point",
|
||||
"sketch.circle.three_point",
|
||||
"sketch.arc.center_point",
|
||||
"sketch.ellipse",
|
||||
"sketch.slot.center_to_center",
|
||||
"sketch.slot.overall",
|
||||
"sketch.slot.center_point",
|
||||
"sketch.slot.arc_center",
|
||||
"sketch.slot.arc_three_point",
|
||||
"sketch.polygon.inscribed",
|
||||
"sketch.polygon.circumscribed",
|
||||
"sketch.polygon.edge",
|
||||
];
|
||||
|
||||
/// A sketch creator as `command` names it: one `dimension_roles` knows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Creator(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for Creator {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if crate::dimension_roles(&s).is_some() => Ok(Creator(s)),
|
||||
Value::String(_) => Err(D::Error::custom(whole("unsupported sketch creator"))),
|
||||
_ => Err(D::Error::custom(whole("`command` must name a supported sketch creator"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Creator {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Creator".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A sketch creation command whose shape can be dimensioned.",
|
||||
"type": "string",
|
||||
"enum": CREATORS
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The creator's own parameters: an object (`null` is refused).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreatorParams(pub Map<String, Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for CreatorParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(o) => Ok(CreatorParams(o)),
|
||||
_ => Err(D::Error::custom(whole("`params` must be an object"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CreatorParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CreatorParams".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The parameters of the creator `command` names, as that command takes them. The creator checks them.",
|
||||
"type": "object"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The sizes to place, by role. Each value is a number or an expression of at most 4096
|
||||
/// characters, as `params::expr` reads it (`null` is refused).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DimensionValues(pub Map<String, Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for DimensionValues {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let Value::Object(o) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`dimensions` must be an object")));
|
||||
};
|
||||
for value in o.values() {
|
||||
if params::expr(&json!({"value": value}), "value").is_none_or(|e| e.len() > 4096) {
|
||||
return Err(D::Error::custom(whole("dimension values must be bounded expressions or finite numbers")));
|
||||
}
|
||||
}
|
||||
Ok(DimensionValues(o))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DimensionValues {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DimensionValues".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let length = generator.subschema_for::<LengthExpr>();
|
||||
let angle = generator.subschema_for::<AngleExpr>();
|
||||
json_schema!({
|
||||
"description": "The sizes to place, each by its role: a number (in the design's unit) or an expression, or `name = expression`, which also defines the user parameter `name`. The roles a creator takes are its own: a line or midpoint line takes `length` and `angle`; a rectangle `width` and `height`; a circle `diameter` or `radius` (not both); an arc by centre `radius` and `sweep`; an ellipse `major_radius` and `minor_radius`; a centre-to-centre, overall or centre-point slot `length` and `width`; a centre arc slot `radius`, `sweep` and `width`; a three-point arc slot `width`; an inscribed or circumscribed polygon `radius`; an edge polygon `radius` or `length` (not both). A role the creator does not take is refused.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"length": length,
|
||||
"width": length,
|
||||
"height": length,
|
||||
"diameter": length,
|
||||
"radius": length,
|
||||
"major_radius": length,
|
||||
"minor_radius": length,
|
||||
"angle": angle,
|
||||
"sweep": angle
|
||||
},
|
||||
"additionalProperties": length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a shape and dimension it in one undo step. `command` names the sketch creator and
|
||||
/// `params` its parameters; `dimensions` gives the sizes to place, by role, as driving
|
||||
/// dimensions. With `as_drawn` every other size of the shape is placed at its size as drawn
|
||||
/// (a size the sketch already determines is left out); with `locate` the shape is then placed
|
||||
/// from the sketch origin with horizontal and vertical distances, only where each takes freedom
|
||||
/// away. Either one makes `dimensions` optional. A size the sketch already has is placed as a
|
||||
/// driven (reference) dimension rather than refused.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = draw_required)]
|
||||
pub struct DrawDimensionedParams {
|
||||
/// The sketch creation command to run: one of the listed creators.
|
||||
#[serde(default, deserialize_with = "command")]
|
||||
#[schemars(with = "Creator")]
|
||||
pub command: Option<Creator>,
|
||||
/// The creator's own parameters, as it takes them (`sketch` among them, to draw in a sketch
|
||||
/// other than the active one). A `sweep` dimension replaces the creator's `sweep` and removes
|
||||
/// its `end`.
|
||||
#[serde(default, deserialize_with = "creator_params")]
|
||||
#[schemars(with = "CreatorParams")]
|
||||
pub params: Option<CreatorParams>,
|
||||
/// The sizes to place, by role. Required unless `as_drawn` or `locate` is true.
|
||||
#[serde(default, deserialize_with = "dimension_values")]
|
||||
#[schemars(with = "DimensionValues")]
|
||||
pub dimensions: Option<DimensionValues>,
|
||||
/// Also dimension every other size of the shape at its size as drawn. A radius or diameter
|
||||
/// given takes the place of the other (as an edge polygon's side length does its radius); a
|
||||
/// circle otherwise takes its diameter. Default: false.
|
||||
#[serde(default)]
|
||||
pub as_drawn: Option<bool>,
|
||||
/// Then place the shape from the sketch origin until nothing of it is left free: horizontal
|
||||
/// and vertical distances from the origin to its points, centres first, each kept only where
|
||||
/// it removes freedom, so a shape anchored to the origin or to placed geometry gets none. A
|
||||
/// point on an axis goes onto that axis instead of getting a distance of 0. Default: false.
|
||||
#[serde(default)]
|
||||
pub locate: Option<bool>,
|
||||
}
|
||||
|
||||
fn command<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Creator>, D::Error> {
|
||||
Creator::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn creator_params<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CreatorParams>, D::Error> {
|
||||
CreatorParams::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
fn dimension_values<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DimensionValues>, D::Error> {
|
||||
DimensionValues::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// `command` and `params` are always needed; `dimensions` unless `as_drawn` or `locate` is true.
|
||||
fn draw_required(schema: &mut Schema) {
|
||||
require(schema, "command");
|
||||
require(schema, "params");
|
||||
schema.insert(
|
||||
"anyOf".into(),
|
||||
json!([
|
||||
{"required": ["dimensions"]},
|
||||
{"required": ["as_drawn"], "properties": {"as_drawn": {"enum": [true]}}},
|
||||
{"required": ["locate"], "properties": {"locate": {"enum": [true]}}}
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// What `sketch.draw.dimensioned` returns: what the creator returned, then the dimensions placed.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[schemars(transform = creator_reply)]
|
||||
pub struct DrawDimensionedResult {
|
||||
/// What the creator returned, as that command returns it (`curves`, `constraints`, `sketch`,
|
||||
/// `created`, and for some creators `sides`, `corners`, `centre_arc` or `support_circle`).
|
||||
#[serde(flatten)]
|
||||
pub drawn: Map<String, Value>,
|
||||
/// The `sketch.dimension` reply of each dimension placed, by role.
|
||||
pub dimensions: Map<String, Value>,
|
||||
/// The names of the user parameters a typed `name = expression` defined. Only when there
|
||||
/// are any.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub defined: Vec<String>,
|
||||
/// With `locate`: the `sketch.dimension` or `sketch.constraint.coincident` replies of what
|
||||
/// placed the shape, in order (empty when nothing was left to place).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub located: Option<Vec<Value>>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong. When the call
|
||||
/// itself is fine, the creator's own warnings about `params` are kept here.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl DrawDimensionedResult {
|
||||
/// The creator's reply `drawn` with the dimensions placed. A key the creator's reply and this
|
||||
/// result both have takes this result's value, as the handler always set them over it.
|
||||
pub fn new(drawn: Value, dimensions: Map<String, Value>, defined: Vec<String>, located: Option<Vec<Value>>) -> Self {
|
||||
let mut drawn = match drawn {
|
||||
Value::Object(o) => o,
|
||||
_ => Map::new(),
|
||||
};
|
||||
let mut param_warnings = Vec::new();
|
||||
if let Some(Value::Array(warnings)) = drawn.get("param_warnings")
|
||||
&& warnings.iter().all(Value::is_string)
|
||||
{
|
||||
param_warnings = warnings.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
|
||||
drawn.remove("param_warnings");
|
||||
}
|
||||
drawn.remove("dimensions");
|
||||
if !defined.is_empty() {
|
||||
drawn.remove("defined");
|
||||
}
|
||||
if located.is_some() {
|
||||
drawn.remove("located");
|
||||
}
|
||||
DrawDimensionedResult { drawn, dimensions, defined, located, param_warnings }
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys most creators' replies have, which depend on the creator (none is always there).
|
||||
fn creator_reply(schema: &mut Schema) {
|
||||
let strings = json!({"type": "array", "items": {"type": "string"}});
|
||||
let properties = json!({
|
||||
"curves": strings,
|
||||
"constraints": strings,
|
||||
"sketch": {"type": "object", "description": "The sketch's solve: `{solved, dof}`."},
|
||||
"created": {"type": "object", "description": "What the creator added: `{points, curves, constraints, dimensions}`."},
|
||||
"sides": {"type": "object", "description": "A rectangle's sides {bottom, right, top, left}, line ids."},
|
||||
"corners": {"type": "object", "description": "A rectangle's corners {bottom_left, bottom_right, top_right, top_left}, point ids."},
|
||||
"centre_arc": {"type": "string", "description": "A centre arc slot's middle arc."},
|
||||
"support_circle": {"type": "string", "description": "A polygon's construction circle."}
|
||||
});
|
||||
if let (Some(Value::Object(existing)), Value::Object(extra)) = (schema.get_mut("properties"), properties) {
|
||||
for (k, v) in extra {
|
||||
existing.entry(k).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dimensioned_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,234 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CREATORS, DrawDimensionedParams, DrawDimensionedResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
const ID: &str = "sketch.draw.dimensioned";
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn scene(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s.param_check = level;
|
||||
s
|
||||
}
|
||||
|
||||
fn matches(reply: &Value) {
|
||||
let schema = output_schema_for::<DrawDimensionedResult>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DrawDimensionedParams>(), &["as_drawn", "command", "dimensions", "locate", "params"]);
|
||||
}
|
||||
|
||||
/// The creators in the schema are exactly the ones `dimension_roles` knows.
|
||||
#[test]
|
||||
fn the_creator_list_is_the_one_dimension_roles_has() {
|
||||
for c in crate::cmd::command_specs() {
|
||||
assert_eq!(crate::dimension_roles(c.id).is_some(), CREATORS.contains(&c.id), "{}", c.id);
|
||||
}
|
||||
for creator in CREATORS {
|
||||
assert!(crate::cmd::find_command(creator).is_some(), "{creator}");
|
||||
assert!(crate::dimension_roles(creator).is_some(), "{creator}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let line = json!({"points": [[0, 0], [10, 0]]});
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
(ID, json!({})),
|
||||
(ID, json!({"command": null, "params": line, "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": 5, "params": line, "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "sketch.point", "params": line, "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "", "params": line, "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "sketch.line", "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "sketch.line", "params": null, "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "sketch.line", "params": [1], "dimensions": {"length": 5}})),
|
||||
(ID, json!({"command": "sketch.line", "params": line})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null, "locate": true})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": [1]})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": null}})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": true}})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": ""}})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": "x".repeat(4097)}})),
|
||||
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": [5]}})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong_with_a_key() {
|
||||
let said = |p: Value| issues(ID, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let line = json!({"points": [[0, 0], [10, 0]]});
|
||||
assert_eq!(said(json!({"command": "sketch.point", "params": line})), ["unsupported sketch creator"]);
|
||||
assert_eq!(said(json!({"command": 3, "params": line})), ["`command` must name a supported sketch creator"]);
|
||||
assert_eq!(said(json!({"command": "sketch.line", "params": 1})), ["`params` must be an object"]);
|
||||
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": 1})), ["`dimensions` must be an object"]);
|
||||
assert_eq!(
|
||||
said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}})),
|
||||
["dimension values must be bounded expressions or finite numbers"]
|
||||
);
|
||||
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5}, "lenght": 1})).len(), 1);
|
||||
// Left out, the handler's message is what a caller gets, not the check's.
|
||||
assert!(said(json!({})).is_empty());
|
||||
}
|
||||
|
||||
/// The types read each key as the handler does, and the schema takes what the handler takes.
|
||||
#[test]
|
||||
fn the_schema_takes_what_the_handler_reads() {
|
||||
let schema = input_schema_for::<DrawDimensionedParams>();
|
||||
let schema = schema.as_value();
|
||||
let line = json!({"points": [[0, 0], [10, 0]]});
|
||||
for (p, takes) in [
|
||||
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5, "angle": "30 deg"}}), true),
|
||||
(json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}, "dimensions": {"diameter": "width = 20"}}), true),
|
||||
(json!({"command": "sketch.line", "params": line, "as_drawn": true}), true),
|
||||
(json!({"command": "sketch.line", "params": line, "locate": true}), true),
|
||||
(json!({"command": "sketch.line", "params": line, "as_drawn": false, "locate": true}), true),
|
||||
(json!({"command": "sketch.line", "params": line}), false),
|
||||
(json!({"command": "sketch.line", "params": line, "as_drawn": false}), false),
|
||||
(json!({"params": line, "as_drawn": true}), false),
|
||||
(json!({"command": "sketch.line", "as_drawn": true}), false),
|
||||
(json!({"command": "sketch.point", "params": line, "as_drawn": true}), false),
|
||||
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}}), false),
|
||||
] {
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
// The type agrees with the schema about the keys it reads.
|
||||
if takes {
|
||||
assert!(issues(ID, &p).is_empty(), "{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every creator, in every form the tests use (typed, as drawn, located), in Warn: the reply
|
||||
/// matches the output schema and carries no warning, so the contract accepts what works today.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let calls = [
|
||||
("sketch.line", json!({"points": [[0, 0], [10, 3]]}), json!({"length": 12, "angle": "20"})),
|
||||
("sketch.line.midpoint", json!({"mid": [3, 4], "end": [9, 12]}), json!({"length": 12})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 5]}), json!({"width": 20, "height": "h = 8"})),
|
||||
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5]}), json!({"width": 20})),
|
||||
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [20, 10], "radius": 2}), json!({"width": 30})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 20})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"radius": "r = 4"})),
|
||||
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 6], "minor_radius": 4}), json!({"major_radius": 12})),
|
||||
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6}), json!({"radius": 8})),
|
||||
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 6}), json!({"length": 7})),
|
||||
];
|
||||
for (command, params, dimensions) in calls {
|
||||
for extra in [json!({}), json!({"as_drawn": true}), json!({"as_drawn": true, "locate": true}), json!({"locate": true})] {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let mut p = json!({"command": command, "params": params, "dimensions": dimensions});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
matches(&reply);
|
||||
assert!(reply["dimensions"].is_object(), "{reply}");
|
||||
assert_eq!(reply.get("located").is_some(), p.get("locate") == Some(&json!(true)), "{reply}");
|
||||
}
|
||||
}
|
||||
// Typed values alone (no flags), a definition and `locate`: `defined` and `located` appear.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let reply = run(
|
||||
&mut s,
|
||||
ID,
|
||||
json!({"command": "sketch.circle.center", "params": {"center": [3, 4], "radius": 3}, "dimensions": {"diameter": "w = 20"}, "locate": true}),
|
||||
);
|
||||
matches(&reply);
|
||||
assert_eq!(reply["defined"], json!(["w"]), "{reply}");
|
||||
assert!(reply["located"].as_array().is_some_and(|l| !l.is_empty()), "{reply}");
|
||||
}
|
||||
|
||||
/// A drawing for each creator, as the handler's own tests use.
|
||||
fn sample(creator: &str) -> Value {
|
||||
match creator {
|
||||
"sketch.line" => json!({"points":[[0,0],[6,8]]}),
|
||||
"sketch.line.midpoint" => json!({"mid":[3,4],"end":[9,12]}),
|
||||
"sketch.rectangle.two_point" => json!({"p0":[0,0],"p1":[30,20]}),
|
||||
"sketch.rectangle.center" => json!({"center":[0,0],"corner":[15,10]}),
|
||||
"sketch.rectangle.three_point" => json!({"p0":[0,0],"p1":[30,10],"p2":[25,25]}),
|
||||
"sketch.rectangle.rounded" => json!({"p0":[0,0],"p1":[30,20],"radius":3}),
|
||||
"sketch.circle.center" => json!({"center":[0,0],"radius":5}),
|
||||
"sketch.circle.two_point" => json!({"p0":[0,0],"p1":[10,0]}),
|
||||
"sketch.circle.three_point" => json!({"p0":[0,0],"p1":[10,0],"p2":[5,5]}),
|
||||
"sketch.arc.center_point" => json!({"center":[0,0],"start":[10,0],"end":[0,10]}),
|
||||
"sketch.ellipse" => json!({"center":[0,0],"major":[10,6],"minor_radius":4}),
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => json!({"p0":[2,3],"p1":[22,3],"width":4}),
|
||||
"sketch.slot.center_point" => json!({"center":[12,3],"end":[22,3],"width":4}),
|
||||
"sketch.slot.arc_center" => json!({"center":[0,0],"start":[20,0],"end":[0,20],"width":6}),
|
||||
"sketch.slot.arc_three_point" => json!({"start":[20,0],"through":[14.142136,14.142136],"end":[0,20],"width":6}),
|
||||
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => json!({"center":[4,5],"radius":10,"sides":6}),
|
||||
"sketch.polygon.edge" => json!({"p0":[0,0],"p1":[10,0],"sides":6}),
|
||||
other => panic!("{other}: add a sample drawing for it"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every creator, as drawn and located, in Warn: a reply that matches, with no warning.
|
||||
#[test]
|
||||
fn every_creator_replies_as_drawn_and_located() {
|
||||
for creator in CREATORS {
|
||||
for flags in [json!({"as_drawn": true}), json!({"locate": true}), json!({"as_drawn": true, "locate": true})] {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let mut p = json!({"command": creator, "params": sample(creator)});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(flags.as_object().cloned().unwrap_or_default()));
|
||||
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
matches(&reply);
|
||||
assert!(reply["dimensions"].is_object() && reply["curves"].is_array(), "{reply}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_creator_passes_the_check() {
|
||||
for creator in CREATORS {
|
||||
// The creator refuses empty parameters in its own words; the check says nothing.
|
||||
assert!(issues(ID, &json!({"command": creator, "params": {}, "as_drawn": true})).is_empty(), "{creator}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn and says so, with a reply that matches; strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}, "dimensions": {"diameter": 10}, "as_draw": true});
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let reply = run(&mut s, ID, p.clone());
|
||||
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
|
||||
let schema = output_schema_for::<DrawDimensionedResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
|
||||
assert!(scene(ParamCheck::Strict).execute(ID, &p).is_err());
|
||||
}
|
||||
|
||||
/// A misspelt key of the creator and one of the call itself are both reported in Warn.
|
||||
#[test]
|
||||
fn warn_keeps_the_creators_warnings_beside_its_own() {
|
||||
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3, "constructon": true}, "dimensions": {"diameter": 10}, "as_draw": true});
|
||||
let reply = run(&mut scene(ParamCheck::Warn), ID, p);
|
||||
let warnings = reply["param_warnings"].as_array().unwrap_or_else(|| panic!("{reply}"));
|
||||
assert_eq!(warnings.len(), 2, "{reply}");
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("constructon"))), "{reply}");
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("as_draw"))), "{reply}");
|
||||
}
|
||||
|
||||
/// The reply is what the handler returned before: the creator's keys, `dimensions`, `defined`,
|
||||
/// `located`, and the creator's own `param_warnings` kept.
|
||||
#[test]
|
||||
fn the_reply_keeps_the_creators_keys() {
|
||||
let creator = json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": "old", "defined": ["x"]});
|
||||
let none = DrawDimensionedResult::new(creator.clone(), serde_json::Map::new(), Vec::new(), None);
|
||||
let json = serde_json::to_value(&none).unwrap_or_default();
|
||||
assert_eq!(json, json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": {}, "defined": ["x"]}));
|
||||
let all = DrawDimensionedResult::new(creator, serde_json::Map::new(), vec!["w".into()], Some(vec![json!(1)]));
|
||||
let json = serde_json::to_value(&all).unwrap_or_default();
|
||||
assert_eq!(json["defined"], json!(["w"]));
|
||||
assert_eq!(json["located"], json!([1]));
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Contracts for Split Body and Split Face in `cmd/features.rs` (#13127). Both reply with the plain
|
||||
//! feature reply, [`super::features::FeatureResult`].
|
||||
//!
|
||||
//! Each takes one cutting tool, and the handler reads the first it finds in this order: a face of
|
||||
//! a body (`tool`), a sketch, then a plane. A key that is `null` counts as not given. The
|
||||
//! contracts check the form of every tool key a call gives, so in strict a call that gives a good
|
||||
//! `tool` and a malformed `plane` is refused, where today the plane is ignored. A call that gives
|
||||
//! no tool is the handler's to refuse, with its message for a missing `plane`; whether a body,
|
||||
//! sketch, line or plane exists is the handler's to check too.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::{FaceRefs, FeatureCommon, PlaneParam, SketchRef, ToolFace, feature_common};
|
||||
|
||||
/// Cut a body into pieces along a plane, a face of another body (its surface extended), or a
|
||||
/// straight sketch line (along the sketch's normal). Each piece becomes a body.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SplitBodyParams {
|
||||
/// The body to split, by name.
|
||||
#[serde(deserialize_with = "body")]
|
||||
pub body: String,
|
||||
/// A face of a body to split along, its surface extended. Read when neither `sketch` nor
|
||||
/// `line` is given.
|
||||
#[serde(default)]
|
||||
pub tool: Option<ToolFace>,
|
||||
/// With `line`: the sketch, by feature id (not its name), whose line splits the body.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchFeatureId>,
|
||||
/// With `sketch`: a straight line's id; the body is split by the plane through it along the
|
||||
/// sketch's normal, which follows the line when the sketch changes.
|
||||
#[serde(default, deserialize_with = "line")]
|
||||
pub line: Option<String>,
|
||||
/// The plane to split across. Read when none of `tool`, `sketch` and `line` is given; then it
|
||||
/// is required.
|
||||
#[serde(default)]
|
||||
pub plane: Option<PlaneParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Divide faces of a body along a plane, a face of another body (its surface extended), or a
|
||||
/// sketch's curves (along the sketch's normal), without cutting the body: each face becomes
|
||||
/// several, and the body keeps its shape.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SplitFaceParams {
|
||||
/// The faces to split.
|
||||
pub faces: FaceRefs,
|
||||
/// The body the faces are on, by name: where faces of several bodies meet at a point, whose
|
||||
/// face it is. Default: the body each point or name finds.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// A face of a body to split along, its surface extended.
|
||||
#[serde(default)]
|
||||
pub tool: Option<ToolFace>,
|
||||
/// A sketch, by id or name, whose curves split the faces along its normal. Read when no
|
||||
/// `tool` is given.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
|
||||
/// required.
|
||||
#[serde(default)]
|
||||
pub plane: Option<PlaneParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Split Body's `body`, with the handler's message for anything that isn't text.
|
||||
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(D::Error::custom(whole("`body` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split Body's `line`, with the handler's message for anything that isn't text. `null` is no
|
||||
/// line, as the handler reads it.
|
||||
fn line<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`line` must name a line of the sketch"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split Body's `sketch`: a sketch's feature id, a whole number from 0 (read with
|
||||
/// `Value::as_u64`). Unlike Split Face's [`SketchRef`], a name is refused, with the handler's
|
||||
/// message. Whether a sketch has that id is the handler's to check.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SketchFeatureId(pub u64);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchFeatureId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_u64().map(SketchFeatureId).ok_or_else(|| D::Error::custom(whole("`sketch` must be a sketch feature id")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchFeatureId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchFeatureId".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A sketch's feature id.", "type": "integer", "minimum": 0, "maximum": u64::MAX})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "split_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,210 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SplitBodyParams, SplitFaceParams};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SplitBodyParams>(),
|
||||
&["body", "body_name", "body_names", "line", "name", "participants", "plane", "sketch", "tool"],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<SplitFaceParams>(),
|
||||
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
|
||||
);
|
||||
}
|
||||
|
||||
/// The design the split calls work on, at `level`, and the sketch ids `Cuts` and `Ring`:
|
||||
/// - `B`, a 40 x 30 x 20 mm box from the origin (its top at z = 20);
|
||||
/// - `Tool`, a box whose face at y = 10, extended, crosses `B`'s top (the point [20, 10, 27] is
|
||||
/// on it);
|
||||
/// - the sketch `Cuts` on XY, two lines across `B` at x = 10 (id `cut`) and x = 30;
|
||||
/// - the sketch `Ring` on XY, a circle `c` beside `B`, so no straight line;
|
||||
/// - the construction plane `Mid`, 20 mm along X from YZ, through the middle of `B`.
|
||||
fn design(level: ParamCheck) -> (Session, Value, Value, Value) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run("solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
|
||||
run("solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"}));
|
||||
let cuts = run("sketch.create", json!({"plane": "XY", "name": "Cuts"}))["sketch"].clone();
|
||||
run("sketch.line", json!({"points": [[10, -5], [10, 35]], "ids": ["cut"]}));
|
||||
run("sketch.line", json!({"points": [[30, -5], [30, 35]]}));
|
||||
run("sketch.finish", json!({}));
|
||||
let ring = run("sketch.create", json!({"plane": "XY", "name": "Ring"}))["sketch"].clone();
|
||||
run("sketch.circle.center", json!({"center": [60, 60], "radius": 2, "id": "c"}));
|
||||
run("sketch.finish", json!({}));
|
||||
let mid = run("construct.plane.offset", json!({"base": "YZ", "offset": 20, "name": "Mid"}))["feature"].clone();
|
||||
assert!(cuts.is_u64() && ring.is_u64() && mid.is_u64(), "{cuts} {ring} {mid}");
|
||||
(s, cuts, ring, mid)
|
||||
}
|
||||
|
||||
/// Calls that fail today give the same message in strict, each on a fresh [`design`]: what the
|
||||
/// contracts check (the body, the faces, each tool's form) and what they leave to the handler (a
|
||||
/// body, sketch, line, plane or face the design lacks, a sketch line that isn't straight, a tool
|
||||
/// point off the tool, and no tool at all). Split Face without `faces` is left out: today it says
|
||||
/// "`faces` must be a list of points or face names", and strict "`faces` is required", as for
|
||||
/// every missing key.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let (_, cuts, ring, _) = design(ParamCheck::Off);
|
||||
let cases = [
|
||||
("solid.split_body", json!({})),
|
||||
("solid.split_body", json!({"plane": "XY"})),
|
||||
("solid.split_body", json!({"body": 5, "plane": "XY"})),
|
||||
("solid.split_body", json!({"body": null, "plane": "XY"})),
|
||||
("solid.split_body", json!({"body": "Nope", "plane": "XY"})),
|
||||
("solid.split_body", json!({"body": "B"})),
|
||||
("solid.split_body", json!({"body": "B", "plane": null, "tool": null, "sketch": null, "line": null})),
|
||||
("solid.split_body", json!({"body": "B", "plane": true})),
|
||||
("solid.split_body", json!({"body": "B", "plane": 7.5})),
|
||||
("solid.split_body", json!({"body": "B", "plane": -1})),
|
||||
("solid.split_body", json!({"body": "B", "plane": "Nowhere"})),
|
||||
("solid.split_body", json!({"body": "B", "plane": "Cuts"})),
|
||||
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0]}})),
|
||||
("solid.split_body", json!({"body": "B", "plane": {"origin": "here", "normal": [1, 0, 0]}})),
|
||||
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": "Tool"})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"point": [20, 10, 27]}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": 5, "point": [20, 10, 27]}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool"}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10]}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": "Nope", "point": [20, 10, 27]}})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [500, 500, 500]}})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": "Cuts", "line": "cut"})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": -1, "line": "cut"})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": 999, "line": "cut"})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": cuts})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": null})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": 5})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "nope"})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": ring, "line": "c"})),
|
||||
("solid.split_body", json!({"body": "B", "line": "cut"})),
|
||||
("solid.split_face", json!({"faces": 5, "plane": "XY"})),
|
||||
("solid.split_face", json!({"faces": [], "plane": "XY"})),
|
||||
("solid.split_face", json!({"faces": [[10, 15]], "plane": "XY"})),
|
||||
("solid.split_face", json!({"faces": [[500, 500, 500]], "plane": "Mid"})),
|
||||
("solid.split_face", json!({"faces": ["NoSuchFace"], "plane": "Mid"})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "Nope", "plane": "Mid"})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]]})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": null, "tool": null, "sketch": null})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": true})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": "Nowhere"})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"normal": [0, 0, 0]}})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": "Tool"})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Tool", "point": "here"}})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Nope", "point": [20, 10, 27]}})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": true})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": "Nope"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let error = |level: ParamCheck| design(level).0.execute(id, &p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13127: each contract takes what its input schema allows: each call's check finds no issue
|
||||
/// exactly when the call matches the schema. A call that gives no tool matches it; the handler
|
||||
/// refuses it.
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let body = input_schema_for::<SplitBodyParams>();
|
||||
let face = input_schema_for::<SplitFaceParams>();
|
||||
for (id, schema, p, takes) in [
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": "XY"}), true),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": 3}), true),
|
||||
(
|
||||
"solid.split_body",
|
||||
&body,
|
||||
json!({"body": "B", "plane": {"origin": [0, "w", 0], "normal": [1, 0, 0]}, "name": "S", "body_names": ["L", "R"]}),
|
||||
true,
|
||||
),
|
||||
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": [0, 0, 0]}}), true),
|
||||
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": {"x": 0, "y": 0, "z": 0}, "side": 1}}), true),
|
||||
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": "cut", "participants": ["*"]}), true),
|
||||
("solid.split_body", &body, json!({"body": "B", "sketch": null, "line": null, "tool": null, "plane": null}), true),
|
||||
("solid.split_body", &body, json!({"body": "B", "line": "cut"}), true),
|
||||
("solid.split_body", &body, json!({"body": ""}), true),
|
||||
("solid.split_body", &body, json!({}), false),
|
||||
("solid.split_body", &body, json!({"body": 5, "plane": "XY"}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": true}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": -1}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": {"origin": [0, 0, 0]}}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "tool": "T"}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T"}}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "tool": {"body": 1, "point": [0, 0, 0]}}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "sketch": "Cuts", "line": "cut"}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "sketch": -2, "line": "cut"}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": 7}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "faces": [[0, 0, 0]]}), false),
|
||||
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "body_name": 3}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY"}), true),
|
||||
(
|
||||
"solid.split_face",
|
||||
&face,
|
||||
json!({"faces": ["F1", {"point": [1, 2, 3]}], "body": "B", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}),
|
||||
true,
|
||||
),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"body": "T", "point": [1, 2, 3]}}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts"}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": 7, "name": "Split", "participants": ["*"]}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": null, "sketch": null, "plane": null, "body": null}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]]}), true),
|
||||
("solid.split_face", &face, json!({"plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [], "plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2]], "plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "body": 5, "plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": true}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"point": [1, 2, 3]}}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
|
||||
] {
|
||||
let schema = schema.as_value();
|
||||
let issues = crate::schema::issues(id, &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{id} {p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13127: both commands reply with `FeatureResult`, with the keys they gave before, for each
|
||||
/// tool: in Warn, without warnings, so their contracts take each call. Split Face changes `B` and
|
||||
/// makes nothing; Split Body cuts `B` in two, so two bodies are made or changed between them.
|
||||
#[test]
|
||||
fn split_body_and_split_face_reply_with_feature_result() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
|
||||
let (_, cuts, _, mid) = design(ParamCheck::Warn);
|
||||
let calls = [
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})),
|
||||
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "B", "plane": "Mid", "name": "Halves"})),
|
||||
("solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
|
||||
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": "Cuts", "tool": null, "plane": null})),
|
||||
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": cuts.clone(), "participants": ["B"]})),
|
||||
("solid.split_body", json!({"body": "B", "plane": {"origin": [20, 0, "0 mm"], "normal": [1, 0, 0]}})),
|
||||
("solid.split_body", json!({"body": "B", "plane": mid, "name": "Halves"})),
|
||||
("solid.split_body", json!({"body": "B", "plane": "Mid", "tool": null, "sketch": null, "line": null})),
|
||||
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
|
||||
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "cut"})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
let (mut s, ..) = design(ParamCheck::Warn);
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, before, "{id} {p}: {r}");
|
||||
let count = |key: &str| r[key].as_array().map_or(0, Vec::len);
|
||||
if id == "solid.split_face" {
|
||||
assert_eq!((count("created_bodies"), r["changed_bodies"].clone()), (0, json!(["B"])), "{id} {p}: {r}");
|
||||
} else {
|
||||
assert!(count("created_bodies") >= 1 && count("created_bodies") + count("changed_bodies") == 2, "{id} {p}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -974,42 +974,6 @@ pick_list!(
|
||||
"The edges, 1 to 1000."
|
||||
);
|
||||
|
||||
/// A face of a body whose surface, extended, cuts like a plane: the `tool` of `solid.split_body`,
|
||||
/// `solid.split_face` and `surface.trim`, which read `tool.body` and then `tool.point` with these
|
||||
/// messages. Other keys in it are ignored, as the handlers ignore them. Whether the body exists
|
||||
/// and the point is on one of its faces is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ToolFace {
|
||||
pub body: String,
|
||||
pub point: Vec3,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ToolFace {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
// In the handlers' order: the body (anything but an object has none), then the point.
|
||||
let body = v.get("body").and_then(Value::as_str).ok_or_else(|| D::Error::custom(whole("`tool.body` must name a body")))?;
|
||||
let point =
|
||||
v.get("point").and_then(params::vec3).ok_or_else(|| D::Error::custom(whole("`tool.point` must be [x, y, z] on the tool face")))?;
|
||||
Ok(ToolFace { body: body.to_string(), point })
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ToolFace {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ToolFace".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A face of a body, its surface extended: the body's name and a point in mm on the face.",
|
||||
"type": "object",
|
||||
"properties": {"body": {"type": "string"}, "point": point},
|
||||
"required": ["body", "point"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
|
||||
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
|
||||
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
|
||||
|
||||
@@ -7,6 +7,7 @@ doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
face.delete
|
||||
file.export
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
@@ -23,6 +24,7 @@ render.preflight
|
||||
render.scene
|
||||
render.start
|
||||
sheet.export_dxf
|
||||
sketch.draw.dimensioned
|
||||
solid.align
|
||||
solid.coil
|
||||
solid.draft
|
||||
@@ -39,6 +41,8 @@ solid.press_pull
|
||||
solid.replace_face
|
||||
solid.revolve
|
||||
solid.shell
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.thread
|
||||
surface.blend
|
||||
surface.extend
|
||||
|
||||
@@ -400,124 +400,3 @@ fn an_insert_after_a_mirror_keeps_its_meshes_through_a_drag() {
|
||||
}
|
||||
assert_as_evaluated_afresh(&s);
|
||||
}
|
||||
|
||||
/// The model of `s` against a fresh evaluation of its design: each body's name, volume, bounds
|
||||
/// and reference mesh. Not the shown meshes: a copy's display triangles can tie differently by
|
||||
/// the pose they were made at, with or without reuse (as in the drag test above).
|
||||
fn assert_model_as_evaluated_afresh(s: &Session) {
|
||||
let mut fresh = solidcraft_doc::Model::new();
|
||||
fresh.no_cache = true;
|
||||
fresh.evaluate(&s.doc);
|
||||
let errors: Vec<_> = fresh.results.iter().filter_map(|r| r.error.as_ref()).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let (got, want) = (s.model.state(), fresh.state());
|
||||
assert_eq!(got.bodies.len(), want.bodies.len());
|
||||
for (a, b) in got.bodies.iter().zip(&want.bodies) {
|
||||
assert_eq!(a.name, b.name);
|
||||
let (va, vb) = (a.measure().unwrap().volume, b.measure().unwrap().volume);
|
||||
assert!((va - vb).abs() < 1e-6, "{}: {va} != {vb}", a.name);
|
||||
assert_eq!(a.mesh().triangles, b.mesh().triangles, "{}", a.name);
|
||||
let (ma, mb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
assert!(ma.min.dist(mb.min) < 1e-6 && ma.max.dist(mb.max) < 1e-6, "{}: {ma:?} != {mb:?}", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving a joint evaluates a mirror of the part it moves again, and the features after it
|
||||
/// only when they read its copy: a box, a sketch and its extrusion, and a cut into another
|
||||
/// body are kept (#12139).
|
||||
#[test]
|
||||
fn a_joint_drive_evaluates_only_what_reads_the_mirrored_copy() {
|
||||
for dependent in [false, true] {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-100, -100], "p1": [-90, -90]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 4, "operation": "new"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [2, -98, 5], "length": 2, "width": 2, "height": 10, "operation": "cut", "targets": ["Later"]}));
|
||||
if dependent {
|
||||
run(&mut s, "solid.move", json!({"bodies": [copy.clone()], "translate": [0, 0, 5]}));
|
||||
}
|
||||
for angle in [20, -35, 60] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
|
||||
let recomputed = s.model.last_recomputed + s.model.last_restored;
|
||||
if dependent {
|
||||
assert_eq!(recomputed, 2, "the mirror and the move of its copy at {angle}°");
|
||||
} else {
|
||||
assert_eq!(recomputed, 1, "only the mirror at {angle}°");
|
||||
}
|
||||
assert_box(world_box(&s, ©), {
|
||||
let (lo, hi) = mirrored_yz(&s, "ArmBody");
|
||||
if dependent { (lo + Vec3::new(0., 0., 5.), hi + Vec3::new(0., 0., 5.)) } else { (lo, hi) }
|
||||
});
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
for command in ["edit.undo", "edit.redo"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved model's pose-only mirror was made at the pose the design had when it was saved: one
|
||||
/// adopted for a design posed otherwise is evaluated again, not taken from the cache (#12139).
|
||||
#[test]
|
||||
fn an_adopted_model_mirrors_at_the_designs_own_pose() {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
|
||||
let saved = s.model.saved(&s.doc).unwrap();
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": 40, "quiet": true}));
|
||||
// As opening installs it, before any evaluation; and once evaluated.
|
||||
s.model = solidcraft_doc::Model::adopt(&s.doc, &saved).unwrap();
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
let before = s.model.recomputed;
|
||||
s.model.evaluate(&s.doc);
|
||||
assert_eq!(s.model.recomputed, before, "the adopted model is current");
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
|
||||
/// A cut revolved about an edge of a mirrored copy turns about it where the copy now is, when
|
||||
/// a joint drive moves the copy: the axis is a pick of the model, not a body by name (#12139).
|
||||
/// (Not checked against a fresh evaluation: one without the cache finds no edge at the pick.)
|
||||
#[test]
|
||||
fn a_revolve_about_a_mirrored_copys_edge_follows_a_joint_drive() {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
|
||||
// An edge of the copy along X, on its lowest Y and Z.
|
||||
let (lo, hi) = world_box(&s, ©);
|
||||
let edge = [(lo.x + hi.x) / 2., lo.y, lo.z];
|
||||
let (x, y) = (edge[0], lo.y - 30.);
|
||||
run(&mut s, "solid.box", json!({"corner": [x - 50., y - 30., -40], "length": 100, "width": 60, "height": 80, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x - 5., y], "p1": [x + 5., y + 10.]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"axis": {"edge": edge}, "angle": "360 deg", "operation": "cut", "targets": ["Block"]}));
|
||||
let block = |s: &Session| s.model.state().body("Block").unwrap().mesh().positions.clone();
|
||||
let before = block(&s);
|
||||
for angle in [5, -5] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
|
||||
assert_eq!(s.model.last_recomputed + s.model.last_restored, 2, "the mirror and the revolve at {angle}°");
|
||||
let after = block(&s);
|
||||
assert_ne!(after, before, "the cut turned about the old axis at {angle}°");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ impl State {
|
||||
State::Failed => "failed",
|
||||
}
|
||||
}
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
fn label(self) -> &'static str {
|
||||
tr(match self {
|
||||
State::Off => "ui.agent_chat_state_off",
|
||||
State::Starting => "ui.agent_chat_state_starting",
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::fonts::RichTextExt as _;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{Container, Edge, Surface};
|
||||
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use solidcraft_engine::panels::Flow;
|
||||
|
||||
use super::*;
|
||||
use crate::ui_kit;
|
||||
@@ -241,7 +240,7 @@ pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// Existing Assistant controls and conversation, inside the shared host's body.
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let rect = ui.max_rect().intersect(ui.clip_rect());
|
||||
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
|
||||
return;
|
||||
@@ -249,7 +248,7 @@ pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow)
|
||||
app.agent_chat.panel_rect = Some(rect);
|
||||
app.agent_chat.drop_rect = Some(rect);
|
||||
crate::scenario::publish_rect("agent_chat:panel", rect);
|
||||
ui_kit::scope(ui, |ui| content(app, ui, flow));
|
||||
ui_kit::scope(ui, |ui| content(app, ui));
|
||||
}
|
||||
|
||||
/// Horizontal room reserved before the history and browser when Assistant is on the left.
|
||||
@@ -905,12 +904,10 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
|
||||
|
||||
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
header(app, ui, left);
|
||||
content(app, ui, Flow::Tall);
|
||||
content(app, ui);
|
||||
}
|
||||
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = flow;
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
@@ -920,61 +917,17 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
ui.label(tr("ui.agent_chat_desktop_only"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
desktop_body(app, ui, flow);
|
||||
desktop_body(app, ui);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let top = ui.cursor().top();
|
||||
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, ui.ctx());
|
||||
}
|
||||
let state = app.agent_chat.state();
|
||||
let bounds = ui.available_rect_before_wrap();
|
||||
let gap = Tokens::get().space_8;
|
||||
// Compact wide cards keep the existing stack until both regions fit.
|
||||
let wide = shared && flow == Flow::Wide && bounds.width() >= MIN_WIDTH * 2.0 + gap;
|
||||
let (interaction_bounds, transcript_bounds) = if wide {
|
||||
let width = (bounds.width() - gap) / 2.0;
|
||||
let left = Rect::from_min_size(bounds.min, vec2(width, bounds.height()));
|
||||
let right = Rect::from_min_max(pos2(bounds.right() - width, bounds.top()), bounds.max);
|
||||
if crate::i18n::panel_direction(&app.preferences.language) == solidcraft_engine::panels::Direction::Rtl {
|
||||
(left, Some(right))
|
||||
} else {
|
||||
(right, Some(left))
|
||||
}
|
||||
} else {
|
||||
(bounds, None)
|
||||
};
|
||||
let root = ui.scope_id();
|
||||
// Orientation changes rectangles, never the retained region identities.
|
||||
let mut interaction = shared.then(|| {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-interaction")).max_rect(interaction_bounds));
|
||||
child.set_clip_rect(ui.clip_rect().intersect(interaction_bounds));
|
||||
child
|
||||
});
|
||||
let (top_height, outcome, remaining) = {
|
||||
let column = interaction.as_mut().unwrap_or(&mut *ui);
|
||||
let top_height = top_controls(app, column, state, shared);
|
||||
let outcome = composer(app, column, state);
|
||||
(top_height, outcome, column.available_rect_before_wrap())
|
||||
};
|
||||
let mut transcript = shared.then(|| {
|
||||
let bounds = transcript_bounds.unwrap_or(remaining);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-transcript")).max_rect(bounds));
|
||||
child.set_clip_rect(ui.clip_rect().intersect(bounds));
|
||||
child
|
||||
});
|
||||
let transcript_height = transcript_body(app, transcript.as_mut().unwrap_or(&mut *ui));
|
||||
let interaction_height = top_height + outcome.height;
|
||||
let height = if wide { interaction_height.max(transcript_height) } else { interaction_height + transcript_height };
|
||||
crate::panel_runtime::report_content_extent(ui, top, height, false);
|
||||
finish_composer(app, ui.ctx(), outcome);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.agent_chat.confirm_new {
|
||||
ui_kit::scope(ui, |ui| {
|
||||
inset(ui, |ui| {
|
||||
@@ -982,7 +935,7 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false));
|
||||
ui.horizontal(|ui| {
|
||||
let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("agent_chat:new_confirm", yes.rect);
|
||||
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
|
||||
if yes.clicked() {
|
||||
new_chat(app);
|
||||
}
|
||||
@@ -993,23 +946,6 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: bool) -> f32 {
|
||||
let top = ui.cursor().top();
|
||||
let reveal_confirmation = shared && {
|
||||
let id = ui.scope_id().with("assistant-confirm-visible");
|
||||
let visible = app.agent_chat.confirm_new;
|
||||
ui.data_mut(|data| {
|
||||
let previous = data.get_temp::<bool>(id).unwrap_or(false);
|
||||
data.insert_temp(id, visible);
|
||||
visible && !previous
|
||||
})
|
||||
};
|
||||
if !shared {
|
||||
new_chat_confirmation(app, ui);
|
||||
}
|
||||
// Shared top controls must yield scroll space to the pinned composer, including
|
||||
// when discovery lists several agents or a long message grows the editor.
|
||||
let picker_max = if shared {
|
||||
@@ -1020,41 +956,24 @@ fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: boo
|
||||
} else {
|
||||
ui.available_height() / 3.0
|
||||
};
|
||||
let provider_scroll = egui::ScrollArea::vertical()
|
||||
let picker = egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_picker_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
.max_height(picker_max)
|
||||
.auto_shrink([false, true]);
|
||||
let provider_scroll = if reveal_confirmation { provider_scroll.vertical_scroll_offset(0.0) } else { provider_scroll };
|
||||
let picker = provider_scroll.show(ui, |ui| {
|
||||
crate::scenario::publish_rect("agent_chat:providers", ui.clip_rect());
|
||||
if shared {
|
||||
new_chat_confirmation(app, ui);
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
picker(app, ui);
|
||||
if shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
});
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
if shared {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
picker(app, ui);
|
||||
if shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
});
|
||||
if !shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct ComposerOutcome {
|
||||
height: f32,
|
||||
send_clicked: bool,
|
||||
stop_clicked: bool,
|
||||
enter: bool,
|
||||
steer: bool,
|
||||
esc: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutcome {
|
||||
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
|
||||
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
|
||||
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
|
||||
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
|
||||
@@ -1229,11 +1148,6 @@ fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutc
|
||||
if pick_clicked {
|
||||
start_pick(app);
|
||||
}
|
||||
ComposerOutcome { height: composer.inner, send_clicked, stop_clicked, enter, steer, esc }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
|
||||
let conversation = egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
@@ -1249,12 +1163,7 @@ fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
|
||||
scroll_while_selecting(ui);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
|
||||
conversation.content_size.y
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOutcome) {
|
||||
let ComposerOutcome { send_clicked, stop_clicked, enter, steer, esc, .. } = outcome;
|
||||
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
|
||||
if esc || stop_clicked {
|
||||
cancel(app);
|
||||
}
|
||||
@@ -1265,13 +1174,13 @@ fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOut
|
||||
// Enter waits with the button.
|
||||
} else if (send_clicked || enter || steer) && something {
|
||||
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
|
||||
send(app, ctx, text);
|
||||
send(app, ui.ctx(), text);
|
||||
if steer {
|
||||
send_now(app, ctx);
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
} else if steer {
|
||||
// Nothing typed: Ctrl+Enter sends what is already queued.
|
||||
send_now(app, ctx);
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2641,7 +2550,7 @@ mod tests {
|
||||
ui.set_width(width);
|
||||
ui_kit::scope(ui, |ui| {
|
||||
header(&mut app, ui, false);
|
||||
desktop_body(&mut app, ui, Flow::Tall);
|
||||
desktop_body(&mut app, ui);
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
|
||||
@@ -4762,249 +4762,3 @@ fn a_picture_only_prompt_and_a_thought_have_no_copy_message_action() {
|
||||
assert!(crate::scenario::handle_at("agent_chat:copy:1").is_none(), "a thought has no copy action");
|
||||
assert!(h.widget("Copy message").is_none(), "nothing to click");
|
||||
}
|
||||
|
||||
fn shared_assistant_harness() -> (crate::scenario::Harness, Script) {
|
||||
let mut h = narrow_chat();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.show_browser = false;
|
||||
h.app.ui.show_timeline = false;
|
||||
h.app.preferences.ai_features = true;
|
||||
h.app.preferences.agent_chat_popped = false;
|
||||
h.app.preferences.language = "en".into();
|
||||
let script = Script::default();
|
||||
h.app.agent_chat.backend = Some(Box::new(script.clone()));
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.app.agent_chat.input = "Retained responsive composer draft".into();
|
||||
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "A retained conversation".into() });
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Retained profile"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let feature = h.app.session.doc.features[0].id;
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
h.frames(6);
|
||||
assert!(h.app.panel_runtime().is_some());
|
||||
assert!(h.app.agent_chat.detecting.is_none(), "the deterministic discovery fixture starts no worker");
|
||||
assert!(script.calls().is_empty());
|
||||
(h, script)
|
||||
}
|
||||
|
||||
fn shared_assistant_float(h: &mut crate::scenario::Harness, width: f32, height: f32) {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, Point, SectionId, SectionLocation, Size};
|
||||
let instance = InstanceId::new("assistant").unwrap();
|
||||
let source = h.app.panel_runtime().unwrap().layout().layout().containing(&instance).unwrap();
|
||||
let origin = Point { x: 300.0, y: 200.0 };
|
||||
let size = Size { width, height };
|
||||
let mutation = if matches!(source.location, SectionLocation::Floating { .. }) {
|
||||
Mutation::SetFloatingRect { section: source.id.clone(), origin, size }
|
||||
} else {
|
||||
Mutation::Move {
|
||||
instance,
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("responsive-assistant").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Floating { origin },
|
||||
size,
|
||||
},
|
||||
}
|
||||
};
|
||||
h.app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: None, mutations: vec![mutation] }).unwrap();
|
||||
h.frames(6);
|
||||
}
|
||||
|
||||
fn shared_assistant_body(h: &crate::scenario::Harness) -> egui::Rect {
|
||||
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|panel| panel.instance.0 == "assistant").unwrap().body.unwrap();
|
||||
egui::Rect::from_min_size(egui::pos2(body.origin.x, body.origin.y), egui::vec2(body.size.width, body.size.height))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_wide_and_compact_present_real_regions_without_losing_the_composer_selection() {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
let input = egui::Id::unique("sc_agent_chat_input");
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(input));
|
||||
h.frames(2);
|
||||
let mut editor = egui::TextEdit::load_state(&h.ctx, input).unwrap();
|
||||
editor.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(2), egui::text::CCursor::new(10))));
|
||||
editor.store(&h.ctx, input);
|
||||
h.frame();
|
||||
let caret = egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range();
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "the docked composer starts focused");
|
||||
let response = h.ctx.read_response(input).unwrap();
|
||||
assert!(response.enabled() && response.interact_rect.is_positive(), "the focused composer is live and interactable");
|
||||
assert!(!caret.unwrap().is_empty(), "exercise a real selection before moving the panel");
|
||||
let draft = h.app.agent_chat.input.clone();
|
||||
let document = h.app.session.doc.clone();
|
||||
let model = h.app.session.model.state();
|
||||
let selection = h.app.session.selection.clone();
|
||||
let camera = h.app.cam;
|
||||
let session = h.app.session.uid;
|
||||
let revision = h.app.session.revision;
|
||||
let undo = h.app.session.undo.len();
|
||||
let dirty = h.app.session.is_dirty();
|
||||
let controllers = h.app.panel_runtime().unwrap().controller_count();
|
||||
for (width, height, wide) in [(900.0, 420.0, true), (500.0, 340.0, false)] {
|
||||
shared_assistant_float(&mut h, width, height);
|
||||
let body = shared_assistant_body(&h);
|
||||
assert!(body.width() > body.height(), "both cases exercise the Wide host flow");
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
|
||||
assert!(body.expand(1.0).contains_rect(conversation));
|
||||
assert!(body.expand(1.0).contains_rect(composer));
|
||||
assert!(body.expand(1.0).contains_rect(switcher));
|
||||
if wide {
|
||||
assert!(conversation.right() <= composer.left(), "a viable Wide body puts the real transcript beside its composer");
|
||||
assert!(conversation.right() <= switcher.left(), "provider controls share the composer's column");
|
||||
assert!(conversation.height() > composer.height(), "the transcript can use the full height of its own column");
|
||||
} else {
|
||||
assert!(conversation.bottom() <= composer.top() + 1.0, "a compact Wide body falls back to the existing vertical composition");
|
||||
assert!(conversation.left() < composer.right() && composer.left() < conversation.right());
|
||||
}
|
||||
assert_eq!(h.app.agent_chat.input, draft);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
}
|
||||
h.app.preferences.language = "ar".into();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(composer.right() <= conversation.left(), "Wide mirrors the two real regions in RTL");
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "RTL keeps the composer focused");
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
h.app.preferences.language = "en".into();
|
||||
h.app
|
||||
.dispatch_panel_layout(LayoutAction::Apply {
|
||||
expected_revision: None,
|
||||
mutations: vec![Mutation::Move {
|
||||
instance: InstanceId::new("assistant").unwrap(),
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("responsive-assistant-side").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: Size { width: 360.0, height: 420.0 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
h.frames(6);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(conversation.bottom() <= composer.top() + 1.0);
|
||||
assert_eq!(h.app.agent_chat.input, draft);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(h.app.session.uid, session);
|
||||
assert!(Arc::ptr_eq(&h.app.session.doc, &document));
|
||||
assert!(Arc::ptr_eq(&h.app.session.model.state(), &model));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.is_dirty(), dirty);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
|
||||
assert!(script.calls().is_empty(), "changing presentation must not submit, cancel or restart the retained service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_transcript_keeps_its_scroll_position_through_responsive_width_changes() {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
h.app.agent_chat.items = (0..80).map(|n| Item::Agent { thought: false, text: format!("Parts list row {n}") }).collect();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
assert_eq!(rows_in_view(&h).last().unwrap().0, 79);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
h.call("ui.scroll", json!({"x":conversation.center().x,"y":conversation.center().y,"delta":1200}));
|
||||
h.frames(30);
|
||||
let before = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before > 0 && before < 70, "exercise a middle position, not an automatic endpoint");
|
||||
for width in [1040.0, 900.0] {
|
||||
shared_assistant_float(&mut h, width, 420.0);
|
||||
let after = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before.abs_diff(after) <= 1, "fixed-height short messages keep their scroll anchor across a width change: {before} → {after}");
|
||||
assert!(!rows_in_view(&h).iter().any(|row| row.0 == 79), "a reparent must not reset stick-to-bottom");
|
||||
}
|
||||
h.app
|
||||
.dispatch_panel_layout(LayoutAction::Apply {
|
||||
expected_revision: None,
|
||||
mutations: vec![Mutation::Move {
|
||||
instance: InstanceId::new("assistant").unwrap(),
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("scrolled-assistant-side").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: Size { width: 360.0, height: 420.0 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
h.frames(6);
|
||||
let after = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before.abs_diff(after) <= 1, "returning to Tall retains the same transcript scroll anchor: {before} → {after}");
|
||||
assert!(script.calls().is_empty());
|
||||
assert!(h.app.agent_chat.backend.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_header_uses_live_status_and_one_click_starts_one_new_chat() {
|
||||
use crate::panel_runtime::{HostControlTarget, HostPointerTarget};
|
||||
use solidcraft_engine::panels::{ControlId, InstanceId};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
let assistant = InstanceId::new("assistant").unwrap();
|
||||
for state in [State::Ready, State::Busy, State::NeedsAuth, State::Exited, State::Failed] {
|
||||
h.app.agent_chat.state = Some(state);
|
||||
h.frames(2);
|
||||
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
|
||||
assert_eq!(presentation.status.as_deref(), Some(state.label()));
|
||||
assert_eq!(presentation.attention, matches!(state, State::NeedsAuth | State::Exited | State::Failed));
|
||||
}
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.app.agent_chat.items.push(Item::Permission { key: 42, title: "Review this local request".into(), options: Vec::new(), answer: None });
|
||||
h.frames(2);
|
||||
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
|
||||
assert_eq!(presentation.status.as_deref(), Some(trf("ui.assistant_waiting", &[("count", "1")]).as_str()));
|
||||
assert!(presentation.attention);
|
||||
assert_eq!(h.app.agent_chat.waiting(), 1, "rendering status never answers the pending request");
|
||||
h.app.agent_chat.items.pop();
|
||||
h.app.agent_chat.pending.push(Pending {
|
||||
text: "Keep this queued request until confirmation".into(),
|
||||
image: false,
|
||||
context: Context::default(),
|
||||
files: Vec::new(),
|
||||
});
|
||||
h.app.preferences.agents =
|
||||
(0..24).map(|n| agents::AgentConfig { id: format!("review-{n}"), name: format!("Review provider {n}"), ..Default::default() }).collect();
|
||||
h.app.agent_chat.picking = Some(true);
|
||||
h.frames(4);
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
assert!(providers.contains(crate::scenario::handle_at("agent_chat:pick:review-0").unwrap()));
|
||||
h.call("ui.scroll", json!({"x":providers.center().x,"y":providers.center().y,"delta":-900}));
|
||||
h.frames(30);
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
|
||||
assert!(
|
||||
switcher.bottom() < providers.top() - solidcraft_theme::Tokens::get().control_height * 4.0,
|
||||
"the real provider scroller is far from its top before New Chat"
|
||||
);
|
||||
assert!(script.calls().is_empty(), "scrolling the deterministic provider list starts no agent");
|
||||
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: assistant, action: ControlId::new("new-chat").unwrap() });
|
||||
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
|
||||
let at = egui::pos2(control.bounds.origin.x + control.bounds.size.width / 2.0, control.bounds.origin.y + control.bounds.size.height / 2.0);
|
||||
click_at(&mut h, at, egui::PointerButton::Primary);
|
||||
h.frames(4);
|
||||
assert!(h.app.agent_chat.confirm_new);
|
||||
assert_eq!(h.app.agent_chat.pending.len(), 1);
|
||||
assert!(script.calls().is_empty(), "opening confirmation cannot invoke New Chat early");
|
||||
let confirm = crate::scenario::rect_of("agent_chat:new_confirm").unwrap();
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(providers.expand(1.0).contains_rect(confirm), "a newly requested confirmation is fully visible inside the actual provider clip");
|
||||
assert!(shared_assistant_body(&h).contains_rect(confirm));
|
||||
assert!(confirm.left() >= composer.left(), "the real confirmation remains reachable in the interaction column");
|
||||
click_at(&mut h, confirm.center(), egui::PointerButton::Primary);
|
||||
h.frames(4);
|
||||
assert_eq!(script.calls().iter().filter(|call| call.as_str() == "new_session").count(), 1);
|
||||
assert!(!script.calls().iter().any(|call| call.starts_with("prompt:") || call == "cancel"));
|
||||
assert_eq!(h.app.agent_chat.input, "Retained responsive composer draft");
|
||||
assert!(h.app.agent_chat.items.is_empty());
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn every_axis_is_drawn_in_one_set_of_colours() {
|
||||
for (name, d, want) in [("x", Vec3::X, t.axis_x), ("y", Vec3::Y, t.axis_y), ("z", Vec3::Z, t.axis_z)] {
|
||||
let at = c + vec2(32.0, 0.0);
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, d.into(), at, 0.5, false, t));
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, &d, at, 0.5, false, t));
|
||||
out.textures_delta.clear();
|
||||
let end = out.shapes.iter().find_map(|s| match &s.shape {
|
||||
egui::Shape::Circle(e) if e.center == at => Some(e.fill),
|
||||
|
||||
@@ -192,7 +192,6 @@ mod file_load_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod fillet_spline_handle_tests;
|
||||
pub mod mouse_gesture;
|
||||
mod nav_bar;
|
||||
pub(crate) mod navigation;
|
||||
pub mod navigation_bar;
|
||||
mod navigation_bar_view;
|
||||
@@ -319,7 +318,6 @@ mod ui_kit_guard_tests;
|
||||
pub mod update;
|
||||
mod value_step;
|
||||
pub(crate) mod view_angles;
|
||||
pub mod view_compass;
|
||||
mod view_confirm;
|
||||
pub mod view_gizmo;
|
||||
pub mod view_pick;
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
//! The navigation bar as a component. The app measures the row, describes each slot and each
|
||||
//! menu as plain data ([`Bar`], [`SlotView`], [`Menu`]) and runs the [`NavAction`] the bar
|
||||
//! returns; the bar keeps which menu is open and reads nothing of the app. Every slot, menu row
|
||||
//! and context item publishes its rect to the widget registry while it is drawn.
|
||||
use crate::ui_kit::{self, BackingTooltipExt as _, Container, NavigationEdge, NavigationIdle, NavigationKind, NavigationSize, Surface};
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
|
||||
/// One slot as the bar draws it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SlotView<'a> {
|
||||
/// Where the slot is in the stored list (what Remove removes).
|
||||
pub index: usize,
|
||||
pub id: &'a str,
|
||||
pub kind: NavigationKind,
|
||||
pub icon: &'a str,
|
||||
/// The text on the button (or beside it, for a menu showing its value).
|
||||
pub text: String,
|
||||
/// The slot's name for assistive technology.
|
||||
pub name: String,
|
||||
/// Hover text, and the shortcut shown with it.
|
||||
pub tip: String,
|
||||
pub key: Option<String>,
|
||||
/// Its tool or toggle is on.
|
||||
pub selected: bool,
|
||||
/// Inert for now (drawn faded, still clickable).
|
||||
pub enabled: bool,
|
||||
/// Editing only: the slot is not shown on this canvas.
|
||||
pub hidden: bool,
|
||||
/// The button shows its text.
|
||||
pub show_text: bool,
|
||||
/// Where it is drawn (unused for a slot in the More menu).
|
||||
pub rect: Rect,
|
||||
/// A second registry name its handle is published under, wherever it is drawn.
|
||||
pub alias: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Labels of the bar's own controls, in the app's language.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BarLabels {
|
||||
pub more: String,
|
||||
pub customise: String,
|
||||
pub hide: String,
|
||||
pub reset: String,
|
||||
pub remove: String,
|
||||
}
|
||||
|
||||
/// A measured bar and its slots.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Bar<'a> {
|
||||
pub rect: Rect,
|
||||
pub size: NavigationSize,
|
||||
pub edge: NavigationEdge,
|
||||
pub idle: NavigationIdle,
|
||||
/// The app's reasons to show the bar at full strength (the view is being turned, the pointer
|
||||
/// is over the view compass); the pointer over the bar and an open menu count on their own.
|
||||
pub active: bool,
|
||||
pub slots: Vec<SlotView<'a>>,
|
||||
/// The slots folded into More, in order.
|
||||
pub overflow: Vec<SlotView<'a>>,
|
||||
/// The More button (`Rect::NOTHING`: nothing folded).
|
||||
pub more: Rect,
|
||||
pub labels: BarLabels,
|
||||
}
|
||||
|
||||
/// A menu a slot opens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Menu {
|
||||
pub entries: Vec<MenuEntry>,
|
||||
/// A pick closes the menu (otherwise it stays open for the next).
|
||||
pub close_on_pick: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MenuEntry {
|
||||
Heading(String),
|
||||
Separator,
|
||||
Row(MenuRow),
|
||||
}
|
||||
|
||||
/// One row of a slot's menu.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MenuRow {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: String,
|
||||
pub selected: bool,
|
||||
pub enabled: bool,
|
||||
/// Text at the row's end (a current value).
|
||||
pub detail: Option<String>,
|
||||
pub tip: Option<String>,
|
||||
/// The registry name its rect is published under.
|
||||
pub target: String,
|
||||
/// Rows of the menu this row opens (empty: the row is picked itself).
|
||||
pub submenu: Vec<MenuRow>,
|
||||
}
|
||||
|
||||
impl MenuRow {
|
||||
pub fn new(id: impl Into<String>, label: impl Into<String>, icon: impl Into<String>, target: impl Into<String>) -> MenuRow {
|
||||
MenuRow { id: id.into(), label: label.into(), icon: icon.into(), enabled: true, target: target.into(), ..MenuRow::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An item of the bar's context menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextItem {
|
||||
Customise,
|
||||
Hide,
|
||||
Reset,
|
||||
/// Remove the slot at this index of the stored list.
|
||||
Remove(usize),
|
||||
}
|
||||
|
||||
/// What the user chose on the bar this frame.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NavAction {
|
||||
/// A slot that is not a menu was clicked (on the bar or in More).
|
||||
Slot(String),
|
||||
/// Row `item` of the menu of slot `menu` (or of its submenu) was picked.
|
||||
Item {
|
||||
menu: String,
|
||||
item: String,
|
||||
},
|
||||
Context(ContextItem),
|
||||
}
|
||||
|
||||
/// Paint the bar's surface and its slots; the slots' responses in order. The live bar and the
|
||||
/// Preferences editor (`editing`: the selected slot's index, if any) share it.
|
||||
pub fn paint_slots(
|
||||
ui: &mut egui::Ui,
|
||||
rect: Rect,
|
||||
size: NavigationSize,
|
||||
edge: NavigationEdge,
|
||||
slots: &[SlotView<'_>],
|
||||
editing: Option<Option<usize>>,
|
||||
) -> Vec<egui::Response> {
|
||||
ui_kit::navigation_surface(ui, rect, size, editing.is_some());
|
||||
let mut responses = Vec::new();
|
||||
for slot in slots {
|
||||
let response = ui
|
||||
.push_id(slot.index, |ui| {
|
||||
ui.scope(|ui| {
|
||||
if slot.hidden {
|
||||
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
|
||||
} else if !slot.enabled {
|
||||
ui.set_opacity(ui.visuals().disabled_alpha);
|
||||
}
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
ui_kit::navigation_separator(ui, slot.rect);
|
||||
ui.interact(
|
||||
slot.rect,
|
||||
ui.scope_id().with("separator"),
|
||||
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
|
||||
)
|
||||
} else {
|
||||
let button = ui_kit::NavigationButton {
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.icon,
|
||||
label: &slot.text,
|
||||
selected: slot.selected,
|
||||
labels: slot.show_text,
|
||||
menu: slot.kind == NavigationKind::Menu,
|
||||
};
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_button_editable(ui, slot.rect, button, true)
|
||||
} else {
|
||||
ui_kit::navigation_button(ui, slot.rect, button)
|
||||
}
|
||||
}
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let chosen = editing.map_or(slot.selected, |selected| selected == Some(slot.index));
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), chosen, &slot.name));
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_edit_outline(ui, slot.rect, slot.hidden, chosen);
|
||||
}
|
||||
ui_kit::navigation_tooltip(&response, rect, edge, &slot.tip, slot.key.as_deref());
|
||||
responses.push(response);
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
||||
/// Draw the live bar, its More button, its context menu and the open menu (`menu` gives a menu
|
||||
/// slot's rows by its id); what the user chose, for the app to run.
|
||||
pub fn show(ui: &mut egui::Ui, bar: &Bar<'_>, menu: impl FnOnce(&str) -> Menu) -> Option<NavAction> {
|
||||
if !bar.rect.is_positive() {
|
||||
return None;
|
||||
}
|
||||
solidcraft_ui_registry::publish_rect("nav:outer", bar.rect);
|
||||
let menu_id = ui.scope_id().with("navigation_bar_menu");
|
||||
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
|
||||
let mut action = None;
|
||||
let active = bar.active || ui.rect_contains_pointer(bar.rect) || !open.is_empty();
|
||||
let opacity = ui_kit::navigation_opacity(bar.idle, active);
|
||||
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
|
||||
let opacity = if bar.idle == NavigationIdle::Hide {
|
||||
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
let mut anchor = bar.more;
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(opacity);
|
||||
let shown: Vec<_> = bar.slots.iter().map(|s| SlotView { selected: s.selected || open == s.id, ..s.clone() }).collect();
|
||||
for (slot, response) in shown.iter().zip(paint_slots(ui, bar.rect, bar.size, bar.edge, &shown, None)) {
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if let Some(alias) = slot.alias {
|
||||
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
|
||||
}
|
||||
context_menu(&response, Some(slot.index), &bar.labels, &mut action);
|
||||
if response.clicked() && slot.kind != NavigationKind::Separator {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
|
||||
} else {
|
||||
action = Some(NavAction::Slot(slot.id.to_owned()));
|
||||
}
|
||||
}
|
||||
if open == slot.id {
|
||||
anchor = response.rect;
|
||||
}
|
||||
}
|
||||
if bar.more.is_positive() {
|
||||
let response = ui_kit::navigation_button(
|
||||
ui,
|
||||
bar.more,
|
||||
ui_kit::NavigationButton {
|
||||
id: "more",
|
||||
kind: NavigationKind::Menu,
|
||||
icon: "dots",
|
||||
label: &bar.labels.more,
|
||||
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
|
||||
labels: false,
|
||||
menu: false,
|
||||
},
|
||||
);
|
||||
ui_kit::navigation_tooltip(&response, bar.rect, bar.edge, &bar.labels.more, None);
|
||||
solidcraft_ui_registry::publish_target("nav:more", response.rect);
|
||||
context_menu(&response, None, &bar.labels, &mut action);
|
||||
if response.clicked() {
|
||||
open = if open == "more" { String::new() } else { "more".to_owned() };
|
||||
}
|
||||
}
|
||||
});
|
||||
context_menu(&background, None, &bar.labels, &mut action);
|
||||
if !open.is_empty() && anchor.is_positive() {
|
||||
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
// Menus open into the view: up from a bar at the bottom, down from one at the top.
|
||||
let (pivot, at) = match bar.edge {
|
||||
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
};
|
||||
let contents = if open == "more" { None } else { Some(menu(&open)) };
|
||||
let popup = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| match &contents {
|
||||
None => overflow_menu(ui, bar, &mut open, &mut action),
|
||||
Some(m) if m.entries.is_empty() => open.clear(),
|
||||
Some(m) => {
|
||||
if let Some(item) = menu_rows(ui, &m.entries) {
|
||||
action = Some(NavAction::Item { menu: open.clone(), item });
|
||||
if m.close_on_pick {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let outside = !child_popup_open
|
||||
&& ui.input(|i| {
|
||||
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !popup.response.rect.contains(p) && !bar.rect.contains(p))
|
||||
});
|
||||
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(menu_id, open));
|
||||
action
|
||||
}
|
||||
|
||||
/// The context menu of the bar (`index`: `None`) or of a slot: Customise, Hide and Reset on the
|
||||
/// bar, Remove on a slot.
|
||||
fn context_menu(response: &egui::Response, index: Option<usize>, labels: &BarLabels, action: &mut Option<NavAction>) {
|
||||
ui_kit::navigation_context(response, |ui| {
|
||||
let items: Vec<(&str, &str, ContextItem)> = match index {
|
||||
None => vec![
|
||||
("customise", &labels.customise, ContextItem::Customise),
|
||||
("hide", &labels.hide, ContextItem::Hide),
|
||||
("reset", &labels.reset, ContextItem::Reset),
|
||||
],
|
||||
Some(index) => vec![("remove", &labels.remove, ContextItem::Remove(index))],
|
||||
};
|
||||
for (id, label, item) in items {
|
||||
let row = ui_kit::NavigationMenuRow::new(id, label, "").show(ui);
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:context:{id}"), row.rect);
|
||||
if row.clicked() {
|
||||
*action = Some(NavAction::Context(item));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// More: the folded slots as rows. A menu slot opens its menu in place of More.
|
||||
fn overflow_menu(ui: &mut egui::Ui, bar: &Bar<'_>, open: &mut String, action: &mut Option<NavAction>) {
|
||||
for slot in &bar.overflow {
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
let start = ui.next_widget_position();
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
continue;
|
||||
}
|
||||
let mut item = ui_kit::NavigationMenuRow::new(slot.id, &slot.name, slot.icon);
|
||||
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
|
||||
item.selected = slot.selected;
|
||||
}
|
||||
let response = item.show(ui);
|
||||
context_menu(&response, Some(slot.index), &bar.labels, action);
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if let Some(alias) = slot.alias {
|
||||
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
|
||||
}
|
||||
ui_kit::navigation_tooltip(&response, response.rect, NavigationEdge::Bottom, &slot.tip, slot.key.as_deref());
|
||||
if response.clicked() {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
*open = slot.id.to_owned();
|
||||
} else {
|
||||
*action = Some(NavAction::Slot(slot.id.to_owned()));
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu's rows; the id of the row picked, if any.
|
||||
fn menu_rows(ui: &mut egui::Ui, entries: &[MenuEntry]) -> Option<String> {
|
||||
let mut picked = None;
|
||||
for entry in entries {
|
||||
match entry {
|
||||
MenuEntry::Heading(title) => ui_kit::navigation_menu_heading(ui, title),
|
||||
MenuEntry::Separator => ui_kit::navigation_menu_separator(ui),
|
||||
MenuEntry::Row(row) => {
|
||||
let response = menu_row(ui, row);
|
||||
if row.submenu.is_empty() {
|
||||
if response.clicked() {
|
||||
picked = Some(row.id.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
|
||||
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for sub in &row.submenu {
|
||||
if menu_row(ui, sub).clicked() {
|
||||
picked = Some(sub.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
fn menu_row(ui: &mut egui::Ui, row: &MenuRow) -> egui::Response {
|
||||
let mut item = ui_kit::NavigationMenuRow::new(&row.id, &row.label, &row.icon);
|
||||
item.selected = row.selected;
|
||||
item.enabled = row.enabled;
|
||||
item.detail = row.detail.as_deref();
|
||||
item.submenu = !row.submenu.is_empty();
|
||||
let response = item.show(ui);
|
||||
let response = match &row.tip {
|
||||
Some(tip) => response.backing_on_hover_text(tip),
|
||||
None => response,
|
||||
};
|
||||
if !row.target.is_empty() {
|
||||
solidcraft_ui_registry::publish_target(&row.target, response.rect);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -622,186 +622,3 @@ fn navigation_board_drops_menu_value_text_before_folding_slots() {
|
||||
assert!(!folded.values && !folded.overflow.is_empty());
|
||||
assert_eq!(folded.more.size(), vec2(32.0, 32.0));
|
||||
}
|
||||
|
||||
/// The bar component driven with plain data, no app: what each click returns.
|
||||
mod component {
|
||||
use super::*;
|
||||
use crate::nav_bar::{Bar, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView, show};
|
||||
use egui::{Event, PointerButton, RawInput};
|
||||
|
||||
fn slot(index: usize, id: &'static str, kind: NavigationKind, x: f32) -> SlotView<'static> {
|
||||
SlotView {
|
||||
index,
|
||||
id,
|
||||
kind,
|
||||
icon: id,
|
||||
text: id.to_owned(),
|
||||
name: id.to_owned(),
|
||||
tip: id.to_owned(),
|
||||
key: None,
|
||||
selected: false,
|
||||
enabled: true,
|
||||
hidden: false,
|
||||
show_text: false,
|
||||
rect: Rect::from_min_size(pos2(x, 304.0), vec2(26.0, 26.0)),
|
||||
alias: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit and Styles on the bar; Zoom and View folded into More.
|
||||
fn bar() -> Bar<'static> {
|
||||
Bar {
|
||||
rect: Rect::from_min_size(pos2(90.0, 300.0), vec2(110.0, 34.0)),
|
||||
size: Default::default(),
|
||||
edge: NavigationEdge::Bottom,
|
||||
idle: NavigationIdle::Always,
|
||||
active: false,
|
||||
slots: vec![
|
||||
slot(0, "fit", NavigationKind::Action, 100.0),
|
||||
SlotView { alias: Some("test:styles"), ..slot(1, "styles", NavigationKind::Menu, 134.0) },
|
||||
],
|
||||
overflow: vec![slot(2, "zoom", NavigationKind::Action, 0.0), slot(3, "view", NavigationKind::Menu, 0.0)],
|
||||
more: Rect::from_min_size(pos2(168.0, 304.0), vec2(26.0, 26.0)),
|
||||
labels: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles closes on a pick; View stays open and has a submenu.
|
||||
fn menu(id: &str) -> Menu {
|
||||
match id {
|
||||
"styles" => Menu { entries: vec![MenuEntry::Row(MenuRow::new("wire", "Wire", "", "test:wire"))], close_on_pick: true },
|
||||
"view" => {
|
||||
let mut preset = MenuRow::new("preset", "Preset", "", "test:preset");
|
||||
preset.submenu = vec![MenuRow::new("fast", "Fast", "", "test:fast")];
|
||||
Menu {
|
||||
entries: vec![
|
||||
MenuEntry::Heading("Show".into()),
|
||||
MenuEntry::Row(MenuRow::new("grid", "Grid", "", "test:grid")),
|
||||
MenuEntry::Separator,
|
||||
MenuEntry::Row(preset),
|
||||
],
|
||||
close_on_pick: false,
|
||||
}
|
||||
}
|
||||
_ => Menu::default(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
bar: Bar<'static>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new() -> Rig {
|
||||
let rig = Rig { ctx: crate::fonts::test_context(), bar: bar() };
|
||||
rig.frame(Vec::new());
|
||||
rig
|
||||
}
|
||||
|
||||
fn frame(&self, events: Vec<Event>) -> Option<NavAction> {
|
||||
solidcraft_ui_registry::clear_handles();
|
||||
let mut action = None;
|
||||
crate::test_support::frame(
|
||||
&self.ctx,
|
||||
RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0))),
|
||||
events,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| action = show(ui, &self.bar, menu),
|
||||
);
|
||||
action
|
||||
}
|
||||
|
||||
/// A press and release of `button` at `at`, then two quiet frames so a popup it opened is
|
||||
/// measured and placed: what the release returned.
|
||||
fn click_with(&self, at: egui::Pos2, button: PointerButton) -> Option<NavAction> {
|
||||
let press = |pressed| vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }];
|
||||
self.frame(vec![Event::PointerMoved(at)]);
|
||||
let action = self.frame(press(true)).or(self.frame(press(false)));
|
||||
self.frame(Vec::new());
|
||||
self.frame(Vec::new());
|
||||
action
|
||||
}
|
||||
|
||||
fn click(&self, at: egui::Pos2) -> Option<NavAction> {
|
||||
self.click_with(at, PointerButton::Primary)
|
||||
}
|
||||
|
||||
fn click_target(&self, name: &str) -> Option<NavAction> {
|
||||
let at = solidcraft_ui_registry::rect_of(name).expect("the target is drawn").center();
|
||||
self.click(at)
|
||||
}
|
||||
|
||||
fn drawn(&self, name: &str) -> bool {
|
||||
solidcraft_ui_registry::rect_of(name).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_or_a_row_of_its_menu_is_returned_and_a_closing_menu_closes() {
|
||||
let rig = Rig::new();
|
||||
assert_eq!(rig.click(pos2(113.0, 317.0)), Some(NavAction::Slot("fit".into())), "an action slot is returned, not run");
|
||||
assert_eq!(rig.click(pos2(147.0, 317.0)), None, "a menu slot opens its menu");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("test:styles"), Some(pos2(147.0, 317.0)), "a slot's alias names it too");
|
||||
assert_eq!(rig.click_target("test:wire"), Some(NavAction::Item { menu: "styles".into(), item: "wire".into() }));
|
||||
assert!(!rig.drawn("test:wire"), "a pick closes a menu that asks for it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_returns_a_folded_slot_and_opens_a_folded_menu_that_stays_open() {
|
||||
let rig = Rig::new();
|
||||
assert_eq!(rig.click_target("nav:more"), None);
|
||||
assert_eq!(rig.click_target("nav:slot:zoom"), Some(NavAction::Slot("zoom".into())), "a folded slot is returned");
|
||||
assert!(!rig.drawn("nav:slot:zoom"), "and More closes");
|
||||
rig.click_target("nav:more");
|
||||
assert_eq!(rig.click_target("nav:slot:view"), None, "a folded menu slot opens its menu");
|
||||
assert!(rig.drawn("test:grid") && !rig.drawn("nav:slot:zoom"), "in place of More");
|
||||
assert_eq!(rig.click_target("test:grid"), Some(NavAction::Item { menu: "view".into(), item: "grid".into() }));
|
||||
assert!(rig.drawn("test:grid"), "a menu that does not close on a pick stays open");
|
||||
assert_eq!(rig.click_target("test:preset"), None, "a row with a submenu opens it");
|
||||
assert_eq!(rig.click_target("test:fast"), Some(NavAction::Item { menu: "view".into(), item: "fast".into() }), "a submenu row by its own id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_menus_return_customise_hide_reset_and_remove_with_the_slot_index() {
|
||||
let rig = Rig::new();
|
||||
for (id, item) in [("customise", ContextItem::Customise), ("hide", ContextItem::Hide), ("reset", ContextItem::Reset)] {
|
||||
rig.click_with(pos2(94.0, 317.0), PointerButton::Secondary);
|
||||
assert!(!rig.drawn("nav:context:remove"), "the bar's own menu");
|
||||
assert_eq!(rig.click_target(&format!("nav:context:{id}")), Some(NavAction::Context(item)));
|
||||
}
|
||||
rig.click_with(pos2(147.0, 317.0), PointerButton::Secondary);
|
||||
assert!(!rig.drawn("nav:context:hide"), "a slot's menu");
|
||||
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(1))), "removes the slot at its stored index");
|
||||
rig.click_target("nav:more");
|
||||
rig.frame(vec![Event::PointerMoved(pos2(0.0, 0.0))]);
|
||||
let folded = solidcraft_ui_registry::rect_of("nav:slot:view").expect("More is open").center();
|
||||
rig.click_with(folded, PointerButton::Secondary);
|
||||
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(3))), "a folded slot too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_menu_slot_is_named_nav_view_on_the_bar_and_in_more() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_browser = false;
|
||||
h.app.ui.show_timeline = false;
|
||||
h.call("ui.prefs", json!({"navigation_bar":{"slots":["display","fit","home"]}}));
|
||||
h.settle();
|
||||
let on_bar = solidcraft_ui_registry::handle_at("nav:slot:display").expect("the View slot is on the bar");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(on_bar));
|
||||
// Many slots before it in a narrow view fold the View slot into More.
|
||||
h.call(
|
||||
"ui.prefs",
|
||||
json!({"navigation_bar":{"slots":["pan","fit","home","visual_style","grid","ground_shadow","ambient_occlusion","projection","spin","orbit","zoom","display"]}}),
|
||||
);
|
||||
h.call("ui.resize", json!({"width":300,"height":600}));
|
||||
h.settle();
|
||||
assert!(solidcraft_ui_registry::handle_at("nav:slot:display").is_none() && solidcraft_ui_registry::handle_at("nav:view").is_none(), "folded");
|
||||
h.step(&json!({"click":{"handle":"nav:more"}})).unwrap();
|
||||
let in_more = solidcraft_ui_registry::handle_at("nav:slot:display").expect("More lists the View slot");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(in_more));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Measured single-row navigation bar. Commands own all view changes.
|
||||
use crate::nav_bar::{self, Bar, BarLabels, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView};
|
||||
use crate::navigation::NavCaps;
|
||||
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, Slot};
|
||||
use crate::ui_kit::{self, NavigationEdge, NavigationKind};
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
|
||||
use crate::{SolveApp, viewport::NavMode};
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
@@ -259,62 +259,21 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot's hover text: a hidden slot's reason, the current style, the sketch's lock, or its name.
|
||||
fn tip(app: &SolveApp, slot: &Slot, hidden: Option<&str>, key: bool) -> String {
|
||||
if let Some(hidden) = hidden {
|
||||
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: NavigationEdge) -> egui::Response {
|
||||
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
|
||||
let label = if let Some(hidden) = hidden {
|
||||
app.tr(hidden).to_owned()
|
||||
} else if slot.id == "visual_style" {
|
||||
solidcraft_ui_text::format("navbar.value_tip", &[("name", app.tr(slot.label)), ("value", current_style(app))])
|
||||
} else if slot.id == "projection" && projection_locked(app) {
|
||||
app.tr("nav.perspective.sketch").to_owned()
|
||||
} else if slot.id == "zoom" && key {
|
||||
} else if slot.id == "zoom" && key.is_some() {
|
||||
app.tr("navbar.zoom_hold_tip").to_owned()
|
||||
} else {
|
||||
app.tr(slot.label).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot `slot`, at `index` of the stored list, as the bar component draws it.
|
||||
fn slot_view(app: &SolveApp, bar: &BarLayout, slot: &'static Slot, index: usize, rect: Rect, editing: Option<(NavCaps, bool)>) -> SlotView<'static> {
|
||||
let hidden = editing
|
||||
.filter(|(caps, _)| !slot.available(*caps))
|
||||
.map(|(_, render)| if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
|
||||
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
|
||||
let value = slot.kind == NavigationKind::Menu
|
||||
&& slot.id == "visual_style"
|
||||
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue;
|
||||
SlotView {
|
||||
index,
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.current_icon(app.ui.visual_style),
|
||||
text: slot_text(app, slot).to_owned(),
|
||||
name: app.tr(slot.label).to_owned(),
|
||||
tip: tip(app, slot, hidden, key.is_some()),
|
||||
key,
|
||||
selected: editing.is_none() && selected(app, slot.id),
|
||||
enabled: editing.is_some() || !(slot.id == "projection" && projection_locked(app)),
|
||||
hidden: hidden.is_some(),
|
||||
show_text: if slot.kind == NavigationKind::Menu { bar.values && value } else { bar.labels },
|
||||
rect,
|
||||
// The View menu's slot, by the name older scenarios use.
|
||||
alias: (slot.id == "display").then_some("nav:view"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The measured row's slots, each with its index in the stored list (a slot the list no longer
|
||||
/// holds is left out).
|
||||
fn slot_views(app: &SolveApp, bar: &BarLayout, editing: Option<(NavCaps, bool)>) -> Vec<SlotView<'static>> {
|
||||
let mut cursor = 0;
|
||||
let mut views = Vec::new();
|
||||
for (slot, rect) in &bar.slots {
|
||||
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
|
||||
continue;
|
||||
};
|
||||
cursor = index + 1;
|
||||
views.push(slot_view(app, bar, slot, index, *rect, editing));
|
||||
}
|
||||
views
|
||||
};
|
||||
ui_kit::navigation_tooltip(&response, along, edge, &label, key.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
@@ -322,73 +281,107 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = app.viewport.layout.as_ref().filter(|l| l.navigation.is_positive()) else { return };
|
||||
let measured = measured(app, ui.ctx(), layout.content);
|
||||
if !measured.rect.is_positive() {
|
||||
let bar = measured(app, ui.ctx(), layout.content);
|
||||
if !bar.rect.is_positive() {
|
||||
return;
|
||||
}
|
||||
let active = ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|
||||
crate::scenario::publish_rect("nav:outer", bar.rect);
|
||||
let menu_id = ui.scope_id().with("navigation_bar_menu");
|
||||
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
|
||||
let active = ui.rect_contains_pointer(bar.rect)
|
||||
|| ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|
||||
|| app.viewport.axis_drag.is_some()
|
||||
|| app.viewport.primary_nav.is_some()
|
||||
|| app.viewport.orbit_from.is_some()
|
||||
|| (app.viewport.mouse.is_some() && ui.input(|i| i.pointer.middle_down()))
|
||||
|| app.viewport.modifier_nav.is_some()
|
||||
|| app.viewport.spin;
|
||||
let prefs = &app.preferences.navigation_bar;
|
||||
let bar = Bar {
|
||||
rect: measured.rect,
|
||||
size: prefs.size,
|
||||
edge: prefs.edge,
|
||||
idle: prefs.idle,
|
||||
active,
|
||||
slots: slot_views(app, &measured, None),
|
||||
overflow: measured
|
||||
.overflow
|
||||
.iter()
|
||||
.filter_map(|slot| Some(slot_view(app, &measured, slot, prefs.slots.iter().position(|id| id == slot.id)?, Rect::NOTHING, None)))
|
||||
.collect(),
|
||||
more: measured.more,
|
||||
labels: BarLabels {
|
||||
more: app.tr("navbar.more").to_owned(),
|
||||
customise: app.tr("navbar.custom.customise").to_owned(),
|
||||
hide: app.tr("navbar.custom.hide").to_owned(),
|
||||
reset: app.tr("navbar.custom.reset").to_owned(),
|
||||
remove: app.tr("navbar.custom.remove_bar").to_owned(),
|
||||
},
|
||||
|| app.viewport.spin
|
||||
|| !open.is_empty();
|
||||
let opacity = ui_kit::navigation_opacity(app.preferences.navigation_bar.idle, active);
|
||||
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
|
||||
let opacity = if app.preferences.navigation_bar.idle == NavigationIdle::Hide {
|
||||
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
let action = nav_bar::show(ui, &bar, |id| menu(app, id));
|
||||
if let Some(action) = action {
|
||||
run(app, action);
|
||||
// Rows of a menu that stays open show the new state on the next frame, without new input.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run what the bar returned.
|
||||
fn run(app: &mut SolveApp, action: NavAction) {
|
||||
match action {
|
||||
NavAction::Slot(id) => {
|
||||
if let Some(slot) = navigation_bar::find(&id) {
|
||||
activate(app, slot);
|
||||
let mut anchor = bar.more;
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(opacity);
|
||||
for (index, slot, response) in paint_row(app, ui, &bar, &open, None) {
|
||||
let bounds = response.rect;
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
context_menu(app, &response, Some(index));
|
||||
if response.clicked() && slot.kind != NavigationKind::Separator {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
|
||||
} else {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
if slot.id == "display" {
|
||||
crate::scenario::publish_handle("nav:view", bounds.center());
|
||||
}
|
||||
if open == slot.id {
|
||||
anchor = bounds;
|
||||
}
|
||||
}
|
||||
NavAction::Context(ContextItem::Customise) => {
|
||||
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
|
||||
if bar.more.is_positive() {
|
||||
let response = ui_kit::navigation_button(
|
||||
ui,
|
||||
bar.more,
|
||||
ui_kit::NavigationButton {
|
||||
id: "more",
|
||||
kind: NavigationKind::Menu,
|
||||
icon: "dots",
|
||||
label: app.tr("navbar.more"),
|
||||
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
|
||||
labels: false,
|
||||
menu: false,
|
||||
},
|
||||
);
|
||||
ui_kit::navigation_tooltip(&response, bar.rect, app.preferences.navigation_bar.edge, app.tr("navbar.more"), None);
|
||||
crate::scenario::publish_target("nav:more", response.rect);
|
||||
context_menu(app, &response, None);
|
||||
if response.clicked() {
|
||||
open = if open == "more" { String::new() } else { "more".to_owned() };
|
||||
}
|
||||
}
|
||||
NavAction::Context(ContextItem::Hide) => {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":false}));
|
||||
});
|
||||
context_menu(app, &background, None);
|
||||
if !open.is_empty() && anchor.is_positive() {
|
||||
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
// Menus open into the view: up from a bar at the bottom, down from one at the top.
|
||||
let (pivot, at) = match app.preferences.navigation_bar.edge {
|
||||
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
};
|
||||
let menu = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| {
|
||||
match open.as_str() {
|
||||
"visual_style" => {
|
||||
if style_menu(app, ui) {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
"display" => display_menu(app, ui),
|
||||
"more" => overflow_menu(app, ui, &bar.overflow, &mut open),
|
||||
_ => open.clear(),
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let outside = !child_popup_open
|
||||
&& ui.input(|i| {
|
||||
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !menu.response.rect.contains(p) && !bar.rect.contains(p))
|
||||
});
|
||||
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
open.clear();
|
||||
}
|
||||
NavAction::Context(ContextItem::Reset) => {
|
||||
app.preferences.navigation_bar = NavigationBar::default();
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":true}));
|
||||
}
|
||||
NavAction::Context(ContextItem::Remove(index)) => {
|
||||
let _ = app.preferences.navigation_bar.remove(index);
|
||||
}
|
||||
NavAction::Item { menu, item } if menu == "visual_style" => {
|
||||
let _ = app.run("view.visual_style", json!({"style":item}));
|
||||
}
|
||||
NavAction::Item { item, .. } => display_pick(app, &item),
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(menu_id, open));
|
||||
}
|
||||
|
||||
pub(crate) fn current_style(app: &SolveApp) -> &str {
|
||||
@@ -416,131 +409,250 @@ pub(crate) fn paint_row(
|
||||
open: &str,
|
||||
editing: Option<(Option<usize>, NavCaps, bool)>,
|
||||
) -> Vec<(usize, &'static Slot, egui::Response)> {
|
||||
let mut views = slot_views(app, bar, editing.map(|(_, caps, render)| (caps, render)));
|
||||
for view in &mut views {
|
||||
view.selected |= editing.is_none() && open == view.id;
|
||||
ui_kit::navigation_surface(ui, bar.rect, app.preferences.navigation_bar.size, editing.is_some());
|
||||
let mut responses = Vec::new();
|
||||
let mut cursor = 0;
|
||||
for (slot, bounds) in &bar.slots {
|
||||
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
|
||||
continue;
|
||||
};
|
||||
cursor = index + 1;
|
||||
let response = ui
|
||||
.push_id(index, |ui| {
|
||||
let hidden = editing.is_some_and(|(_, caps, _)| !slot.available(caps));
|
||||
ui.scope(|ui| {
|
||||
if hidden {
|
||||
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
|
||||
} else if editing.is_none() && slot.id == "projection" && projection_locked(app) {
|
||||
ui.set_opacity(ui.visuals().disabled_alpha);
|
||||
}
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
ui_kit::navigation_separator(ui, *bounds);
|
||||
ui.interact(
|
||||
*bounds,
|
||||
ui.scope_id().with("separator"),
|
||||
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
|
||||
)
|
||||
} else {
|
||||
let button = ui_kit::NavigationButton {
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.current_icon(app.ui.visual_style),
|
||||
label: slot_text(app, slot),
|
||||
selected: if editing.is_some() { false } else { selected(app, slot.id) || open == slot.id },
|
||||
labels: if slot.kind == NavigationKind::Menu {
|
||||
bar.values
|
||||
&& slot.id == "visual_style"
|
||||
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue
|
||||
} else {
|
||||
bar.labels
|
||||
},
|
||||
menu: slot.kind == NavigationKind::Menu,
|
||||
};
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_button_editable(ui, *bounds, button, true)
|
||||
} else {
|
||||
ui_kit::navigation_button(ui, *bounds, button)
|
||||
}
|
||||
}
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let mut hidden = None;
|
||||
if let Some((selected, caps, render)) = editing {
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected == Some(index), app.tr(slot.label)));
|
||||
ui_kit::navigation_edit_outline(ui, *bounds, !slot.available(caps), selected == Some(index));
|
||||
if !slot.available(caps) {
|
||||
hidden = Some(if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
|
||||
}
|
||||
} else {
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected(app, slot.id) || open == slot.id, app.tr(slot.label))
|
||||
});
|
||||
}
|
||||
// The settings' preview keeps its tips above, wherever the bar sits in the view.
|
||||
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
|
||||
responses.push((index, *slot, tooltip(app, response, slot, hidden, bar.rect, edge)));
|
||||
}
|
||||
// The settings' preview keeps its tips above, wherever the bar sits in the view.
|
||||
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
|
||||
let responses = nav_bar::paint_slots(ui, bar.rect, app.preferences.navigation_bar.size, edge, &views, editing.map(|(selected, _, _)| selected));
|
||||
views.iter().zip(responses).filter_map(|(view, response)| Some((view.index, navigation_bar::find(view.id)?, response))).collect()
|
||||
responses
|
||||
}
|
||||
|
||||
/// The rows of the menu slot `id` opens (none for an unknown one, which closes it).
|
||||
fn menu(app: &SolveApp, id: &str) -> Menu {
|
||||
match id {
|
||||
"visual_style" => style_menu(app),
|
||||
"display" => display_menu(app),
|
||||
_ => Menu::default(),
|
||||
}
|
||||
pub(crate) fn context_menu(app: &mut SolveApp, response: &egui::Response, index: Option<usize>) {
|
||||
ui_kit::navigation_context(response, |ui| {
|
||||
for id in if index.is_none() { &["customise", "hide", "reset"][..] } else { &[][..] } {
|
||||
let key = format!("navbar.custom.{id}");
|
||||
let item = ui_kit::NavigationMenuRow::new(id, app.tr(&key), "").show(ui);
|
||||
crate::scenario::publish_target(&format!("nav:context:{id}"), item.rect);
|
||||
if item.clicked() {
|
||||
match *id {
|
||||
"customise" => {
|
||||
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
|
||||
}
|
||||
"hide" => {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":false}));
|
||||
}
|
||||
_ => {
|
||||
app.preferences.navigation_bar = NavigationBar::default();
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":true}));
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if let Some(index) = index {
|
||||
let item = ui_kit::NavigationMenuRow::new("remove", app.tr("navbar.custom.remove_bar"), "").show(ui);
|
||||
crate::scenario::publish_target("nav:context:remove", item.rect);
|
||||
if item.clicked() {
|
||||
let _ = app.preferences.navigation_bar.remove(index);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Visual style: the four styles, then Translucent (later); a pick closes the menu.
|
||||
fn style_menu(app: &SolveApp) -> Menu {
|
||||
let mut entries: Vec<MenuEntry> = navigation_bar::VISUAL_STYLES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (style, _))| {
|
||||
let mut row = MenuRow::new(
|
||||
*style,
|
||||
app.tr(&format!("navbar.style.{style}")),
|
||||
navigation_bar::visual_style_icon(index as u8),
|
||||
format!("nav:style:{style}"),
|
||||
);
|
||||
row.selected = app.ui.visual_style == index as u8;
|
||||
MenuEntry::Row(row)
|
||||
})
|
||||
.collect();
|
||||
entries.push(MenuEntry::Separator);
|
||||
let mut later = MenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent", "");
|
||||
fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||||
let mut chosen = false;
|
||||
for (index, style) in navigation_bar::VISUAL_STYLES.iter().map(|(style, _)| *style).enumerate() {
|
||||
let label = format!("navbar.style.{style}");
|
||||
let mut item = ui_kit::NavigationMenuRow::new(style, app.tr(&label), navigation_bar::visual_style_icon(index as u8));
|
||||
item.selected = app.ui.visual_style == index as u8;
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("nav:style:{style}"), response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.visual_style", json!({"style":style}));
|
||||
chosen = true;
|
||||
}
|
||||
}
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let mut later = ui_kit::NavigationMenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent");
|
||||
later.enabled = false;
|
||||
later.detail = Some(app.tr("navbar.later").to_owned());
|
||||
entries.push(MenuEntry::Row(later));
|
||||
Menu { entries, close_on_pick: true }
|
||||
later.detail = Some(app.tr("navbar.later"));
|
||||
later.show(ui);
|
||||
chosen
|
||||
}
|
||||
|
||||
/// The View menu: graphics preset, what the view shows, projection, cursor and the assistant.
|
||||
fn display_menu(app: &SolveApp) -> Menu {
|
||||
let row = |id: &str, label: &str, icon: &str, target: &str, selected: bool| {
|
||||
let mut row = MenuRow::new(id, label, icon, target);
|
||||
row.selected = selected;
|
||||
row
|
||||
};
|
||||
let mut entries = vec![MenuEntry::Heading(app.tr("navbar.graphics").to_owned())];
|
||||
fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &mut String) {
|
||||
for slot in slots {
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
let start = ui.next_widget_position();
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
continue;
|
||||
}
|
||||
let mut item = ui_kit::NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
|
||||
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
|
||||
item.selected = selected(app, slot.id);
|
||||
}
|
||||
let response = item.show(ui);
|
||||
let index = app.preferences.navigation_bar.slots.iter().position(|id| id == slot.id);
|
||||
context_menu(app, &response, index);
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if slot.id == "display" {
|
||||
crate::scenario::publish_handle("nav:view", response.rect.center());
|
||||
}
|
||||
if tooltip(app, response.clone(), slot, None, response.rect, NavigationEdge::Bottom).clicked() {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
*open = slot.id.to_owned();
|
||||
} else {
|
||||
activate(app, slot);
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
use ui_kit::{NavigationMenuRow, navigation_menu_heading as heading, navigation_menu_separator as separator};
|
||||
heading(ui, app.tr("navbar.graphics"));
|
||||
let current = crate::prefs::graphics_preset(&app.preferences);
|
||||
let mut preset = MenuRow::new("preset", app.tr("navbar.preset"), "", "view:graphics_preset");
|
||||
preset.detail = Some(app.tr(&format!("ui.graphics_{current}")).to_owned());
|
||||
preset.submenu = [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")]
|
||||
.into_iter()
|
||||
.map(|(id, label)| row(id, app.tr(label), "", &format!("view:graphics_preset:{id}"), current == id))
|
||||
.collect();
|
||||
entries.extend([MenuEntry::Row(preset), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.show").to_owned())]);
|
||||
let detail = format!("ui.graphics_{current}");
|
||||
let mut preset = NavigationMenuRow::new("preset", app.tr("navbar.preset"), "");
|
||||
preset.detail = Some(app.tr(&detail));
|
||||
preset.submenu = true;
|
||||
let response = preset.show(ui);
|
||||
crate::scenario::publish_target("view:graphics_preset", response.rect);
|
||||
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
|
||||
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for (id, label) in [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")] {
|
||||
let mut item = NavigationMenuRow::new(id, app.tr(label), "");
|
||||
item.selected = current == id;
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.show"));
|
||||
for id in ["grid", "ground_shadow", "ambient_occlusion"] {
|
||||
let Some(slot) = navigation_bar::find(id) else { continue };
|
||||
entries.push(MenuEntry::Row(row(
|
||||
slot.id,
|
||||
app.tr(slot.label),
|
||||
slot.current_icon(app.ui.visual_style),
|
||||
&format!("view:{id}"),
|
||||
selected(app, slot.id),
|
||||
)));
|
||||
let mut item = NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
|
||||
item.selected = selected(app, slot.id);
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
entries.extend([MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.projection_heading").to_owned())]);
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.projection_heading"));
|
||||
for (perspective, key) in [(false, "navbar.orthographic"), (true, "navbar.perspective")] {
|
||||
let mut item = row(
|
||||
key,
|
||||
app.tr(key),
|
||||
"perspective",
|
||||
if perspective { "view:projection" } else { "view:orthographic" },
|
||||
app.ui.perspective == perspective,
|
||||
);
|
||||
let mut item = NavigationMenuRow::new(key, app.tr(key), "perspective");
|
||||
item.selected = app.ui.perspective == perspective;
|
||||
item.enabled = !projection_locked(app);
|
||||
item.tip = projection_locked(app).then(|| app.tr("nav.perspective.sketch").to_owned());
|
||||
entries.push(MenuEntry::Row(item));
|
||||
let response = item.show(ui);
|
||||
let response = if projection_locked(app) { response.backing_on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
|
||||
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
|
||||
if response.clicked() && app.ui.perspective != perspective {
|
||||
toggle_projection(app);
|
||||
}
|
||||
}
|
||||
entries.push(MenuEntry::Separator);
|
||||
separator(ui);
|
||||
for (id, label, on) in [
|
||||
("auto_ortho_faces", "Perspective while orbiting, ortho on faces", app.ui.auto_ortho_faces),
|
||||
("tool_cursor", "Show tool at cursor", app.ui.tool_cursor),
|
||||
] {
|
||||
entries.push(MenuEntry::Row(row(id, solidcraft_ui_text::tr_label(label), "select", &format!("view:{id}"), on)));
|
||||
}
|
||||
// The View angles readout under the orientation gizmo (#6032).
|
||||
let mut angles = row("view_angles", app.tr("ui.view_angles"), "select", "view:view_angles", app.preferences.show_view_angles);
|
||||
angles.tip = Some(app.tr("ui.view_angles_help").to_owned());
|
||||
entries.extend([MenuEntry::Row(angles), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.assistant").to_owned())]);
|
||||
entries.push(MenuEntry::Row(row("agent_cursor", app.tr("navbar.agent_cursor"), "select", "view:agent_cursor", app.ui.agent_cursor.show)));
|
||||
if crate::agent_chat::shown(app) {
|
||||
entries.push(MenuEntry::Row(row("agent_chat", app.tr("navbar.agent_chat"), "agent_chat", "view:agent_chat", app.agent_chat.open)));
|
||||
}
|
||||
Menu { entries, close_on_pick: false }
|
||||
}
|
||||
|
||||
/// A View menu pick.
|
||||
fn display_pick(app: &mut SolveApp, item: &str) {
|
||||
match item {
|
||||
"performance" | "quality" => {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":item}));
|
||||
}
|
||||
"navbar.orthographic" | "navbar.perspective" => {
|
||||
if app.ui.perspective != (item == "navbar.perspective") {
|
||||
toggle_projection(app);
|
||||
let mut row = NavigationMenuRow::new(id, solidcraft_ui_text::tr_label(label), "select");
|
||||
row.selected = on;
|
||||
let response = row.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
match id {
|
||||
"auto_ortho_faces" => app.ui.auto_ortho_faces = !on,
|
||||
_ => app.ui.tool_cursor = !on,
|
||||
}
|
||||
}
|
||||
"auto_ortho_faces" => app.ui.auto_ortho_faces = !app.ui.auto_ortho_faces,
|
||||
"tool_cursor" => app.ui.tool_cursor = !app.ui.tool_cursor,
|
||||
"view_angles" => {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
||||
}
|
||||
"agent_cursor" => app.ui.agent_cursor.show = !app.ui.agent_cursor.show,
|
||||
"agent_chat" => {
|
||||
}
|
||||
// The View angles readout under the orientation gizmo (#6032).
|
||||
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
|
||||
angles.selected = app.preferences.show_view_angles;
|
||||
let response = angles.show(ui).backing_on_hover_text(app.tr("ui.view_angles_help"));
|
||||
crate::scenario::publish_target("view:view_angles", response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
||||
}
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.assistant"));
|
||||
let mut cursor = NavigationMenuRow::new("agent_cursor", app.tr("navbar.agent_cursor"), "select");
|
||||
cursor.selected = app.ui.agent_cursor.show;
|
||||
let response = cursor.show(ui);
|
||||
crate::scenario::publish_target("view:agent_cursor", response.rect);
|
||||
if response.clicked() {
|
||||
app.ui.agent_cursor.show = !app.ui.agent_cursor.show;
|
||||
}
|
||||
if crate::agent_chat::shown(app) {
|
||||
let mut chat = NavigationMenuRow::new("agent_chat", app.tr("navbar.agent_chat"), "agent_chat");
|
||||
chat.selected = app.agent_chat.open;
|
||||
let response = chat.show(ui);
|
||||
crate::scenario::publish_target("view:agent_chat", response.rect);
|
||||
if response.clicked() {
|
||||
app.agent_chat.open = !app.agent_chat.open;
|
||||
app.preferences.agent_chat_minimized &= !app.agent_chat.open;
|
||||
}
|
||||
id => {
|
||||
if let Some(slot) = navigation_bar::find(id) {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,9 +682,7 @@ impl AppPanelRuntime {
|
||||
if (self.runtime.environment() != &before || self.runtime.layout().inspect().revision != revision)
|
||||
&& let Some(host) = &mut self.host
|
||||
{
|
||||
// Presentation metadata can revise an unrelated panel.
|
||||
// Scope and focused-owner checks decide input ownership.
|
||||
host.invalidate_geometry();
|
||||
host.invalidate_context();
|
||||
host.reconcile_scope(&self.runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,17 +110,6 @@ impl Product {
|
||||
}
|
||||
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
|
||||
let mut presentation = self.empty_presentation();
|
||||
if self == Self::Assistant {
|
||||
let waiting = app.agent_chat.waiting();
|
||||
let state = app.agent_chat.state();
|
||||
presentation.status = Some(if waiting > 0 {
|
||||
solidcraft_ui_text::format("ui.assistant_waiting", &[("count", &waiting.to_string())])
|
||||
} else {
|
||||
state.label().into()
|
||||
});
|
||||
presentation.attention = waiting > 0
|
||||
|| matches!(state, crate::agent_chat::State::NeedsAuth | crate::agent_chat::State::Exited | crate::agent_chat::State::Failed);
|
||||
}
|
||||
presentation.actions = match self {
|
||||
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
|
||||
.iter()
|
||||
@@ -865,7 +854,7 @@ impl FramePanelContent for ProductionFrame<'_> {
|
||||
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
|
||||
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
|
||||
Product::History => crate::timeline_left::panel_contents(self.app, ui),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
|
||||
}
|
||||
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
|
||||
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
|
||||
|
||||
@@ -12893,92 +12893,6 @@ fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_focus_survives_floating_sizing_unless_a_live_foreign_control_takes_it() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for foreign_focus in [false, true] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
if foreign_focus {
|
||||
raw.prime_foreign_window();
|
||||
}
|
||||
let editor = trace.borrow().editors.last().unwrap().0;
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor));
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame_late(vec![egui::Event::Text(" retained reparent selection".into())], false, |_, _| {});
|
||||
raw.frame(Vec::new(), false);
|
||||
let mut state = egui::TextEdit::load_state(&raw.ctx, editor).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&raw.ctx, editor);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range();
|
||||
assert!(!caret.unwrap().is_empty());
|
||||
let draft = trace.borrow().editors.last().unwrap().3.clone();
|
||||
let key = ControllerKey { instance: id("wrapping-editor"), owner: raw.content.runtime.instance_owner(&id("wrapping-editor")).unwrap() };
|
||||
let renders = raw.content.probe.state(&key).unwrap().renders;
|
||||
if foreign_focus {
|
||||
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(1000.0, 600.0), egui::vec2(180.0, 80.0)));
|
||||
raw.foreign_takes_focus = true;
|
||||
}
|
||||
raw.content
|
||||
.dispatch(vec![Mutation::Move {
|
||||
instance: id("wrapping-editor"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("reparented-editor"),
|
||||
index: 0,
|
||||
location: SectionLocation::Floating { origin: Point { x: 400.0, y: 180.0 } },
|
||||
size: Size { width: 400.0, height: 320.0 },
|
||||
},
|
||||
}])
|
||||
.unwrap();
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.content.probe.state(&key).unwrap().renders, renders, "the new Area sizing pass must not run a hidden body");
|
||||
let foreign = foreign_focus.then(|| raw.foreign_responses.last().unwrap().id);
|
||||
assert_eq!(
|
||||
raw.ctx.memory(|memory| memory.focused()),
|
||||
foreign,
|
||||
"sizing removes the editor; only the real late foreign widget may take focus"
|
||||
);
|
||||
// Stop requesting focus: subsequent passes must retain the person's
|
||||
// choice without repeatedly forcing it after host restoration.
|
||||
raw.foreign_takes_focus = false;
|
||||
for _ in 0..2 {
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), foreign.or(Some(editor)));
|
||||
}
|
||||
assert!(raw.content.probe.state(&key).unwrap().renders > renders);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
if foreign_focus {
|
||||
// A later deliberate focus command may return to the retained
|
||||
// selection. It is separate from the Move tested above.
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
}
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
assert_eq!(raw.content.commands.calls, 0);
|
||||
|
||||
// A retained bookmark must not keep its editor focused after the
|
||||
// owning content becomes unavailable, even before another paint.
|
||||
trace.borrow_mut().unavailable.insert(key.clone());
|
||||
raw.content.tick().unwrap();
|
||||
assert_ne!(raw.content.runtime.layout().availability(&key.instance), Availability::Available);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
raw.host.reconcile_scope(&raw.content.runtime);
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
|
||||
@@ -226,17 +226,6 @@ struct ContentFocus {
|
||||
selection: Option<egui::text::CCursorRange>,
|
||||
}
|
||||
impl ContentFocus {
|
||||
fn logically_presented(&self, runtime: &PanelRuntime) -> bool {
|
||||
let service = runtime.layout();
|
||||
self.scope == runtime_scope(runtime)
|
||||
&& runtime.instance_owner(&self.key.instance).is_ok_and(|owner| owner == self.key.owner)
|
||||
&& service.availability(&self.key.instance) == Availability::Available
|
||||
&& service.layout().instance(&self.key.instance).is_ok_and(|instance| instance.requested_open)
|
||||
&& service.layout().containing(&self.key.instance).is_ok_and(|section| {
|
||||
let availability: AvailabilityMap = section.tabs.iter().map(|instance| (instance.clone(), service.availability(instance))).collect();
|
||||
!section.collapsed && presented_tab(section, &availability) == Some(&self.key.instance)
|
||||
})
|
||||
}
|
||||
fn restore_selection(&self, ctx: &egui::Context) {
|
||||
if let Some(selection) = self.selection
|
||||
&& let Some(mut state) = egui::TextEdit::load_state(ctx, self.id)
|
||||
@@ -611,16 +600,6 @@ impl WorkspacePanelHost {
|
||||
}
|
||||
self.scope = Some(identity);
|
||||
}
|
||||
if let Some(bookmark) = &self.content_focus
|
||||
&& self.owned_focus == Some(bookmark.id)
|
||||
&& !bookmark.logically_presented(runtime)
|
||||
{
|
||||
self.restore_content_focus = false;
|
||||
self.owned_focus = None;
|
||||
if let Some(ctx) = &self.egui_context {
|
||||
ctx.memory_mut(|memory| memory.surrender_focus(bookmark.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn note_reply(&mut self, reply: &LayoutReply) {
|
||||
for effect in &reply.effects {
|
||||
@@ -956,7 +935,7 @@ impl WorkspacePanelHost {
|
||||
// the intended resolved geometry, but never publish it as actual paint
|
||||
// or an input/occlusion shield, even if no further egui pass is allowed.
|
||||
let mut unpainted_instances = Vec::new();
|
||||
for (layer, bounds, sizing) in &unpainted {
|
||||
for (layer, bounds) in &unpainted {
|
||||
let hidden_sections: Vec<_> = frame
|
||||
.layout
|
||||
.sections
|
||||
@@ -967,16 +946,6 @@ impl WorkspacePanelHost {
|
||||
.iter()
|
||||
.flat_map(|section| std::iter::once(section.instance.clone()).chain(section.tabs.iter().map(|tab| tab.instance.clone())))
|
||||
.collect();
|
||||
// A new Area measures before it paints. Preserve only the editor
|
||||
// that still owns focus; restore it after a fresh response returns.
|
||||
if *sizing && ui.is_enabled() && ui.is_visible() && ui.input(|input| input.focused) && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
|
||||
self.restore_content_focus |= self.content_focus.as_ref().is_some_and(|bookmark| {
|
||||
self.owned_focus == Some(bookmark.id)
|
||||
&& ui.memory(|memory| memory.focused()) == Some(bookmark.id)
|
||||
&& bookmark.logically_presented(runtime)
|
||||
&& hidden_sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
|
||||
});
|
||||
}
|
||||
unpainted_instances.extend(hidden_instances.iter().cloned());
|
||||
let clipped = logical_rect(egui_rect(*bounds).intersect(egui_rect(frame.layout.capacity)));
|
||||
// Remove one occurrence: a visible card can occupy the same rect.
|
||||
@@ -1063,12 +1032,6 @@ impl WorkspacePanelHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
let logical_focus_eligible = self.restore_content_focus
|
||||
&& ui.input(|input| input.focused)
|
||||
&& self.content_focus.as_ref().is_some_and(|bookmark| bookmark.logically_presented(runtime));
|
||||
// A passive return cannot outlive deliberate hiding or owner/focus loss.
|
||||
// A modal may still wait while that logical presentation remains valid.
|
||||
self.restore_content_focus &= logical_focus_eligible;
|
||||
if self.restore_content_focus && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
|
||||
let current = ui.memory(|memory| memory.focused());
|
||||
let deliberate = current.is_some_and(|id| ui.ctx().read_response(id).is_some_and(|response| response.gained_focus()));
|
||||
@@ -1077,8 +1040,11 @@ impl WorkspacePanelHost {
|
||||
} else if current.is_none()
|
||||
&& let Some(bookmark) = &self.content_focus
|
||||
{
|
||||
let eligible =
|
||||
self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
|
||||
let eligible = bookmark.scope == scope
|
||||
&& runtime.instance_owner(&bookmark.key.instance).is_ok_and(|owner| owner == bookmark.key.owner)
|
||||
&& runtime.layout().availability(&bookmark.key.instance) == Availability::Available
|
||||
&& frame.layout.sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
|
||||
&& self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
|
||||
if eligible {
|
||||
bookmark.restore_selection(ui.ctx());
|
||||
ui.memory_mut(|memory| memory.request_focus(bookmark.id));
|
||||
@@ -2230,7 +2196,7 @@ struct WorkspaceContentHost<'a> {
|
||||
motion: Motion,
|
||||
commands: &'a mut Vec<HostCommand>,
|
||||
allow_actions: bool,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect, bool)>,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect)>,
|
||||
}
|
||||
|
||||
impl PanelHost for WorkspaceContentHost<'_> {
|
||||
@@ -2281,7 +2247,7 @@ impl PanelHost for WorkspaceContentHost<'_> {
|
||||
matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }).then(|| style.palette(appearance)),
|
||||
|ui, content_clip| {
|
||||
if (section.peek || section.zone == Zone::Floating) && (!ui.is_visible() || ui.is_sizing_pass()) {
|
||||
self.unpainted.push((ui.layer_id(), section.outer, ui.is_sizing_pass()));
|
||||
self.unpainted.push((ui.layer_id(), section.outer));
|
||||
return;
|
||||
}
|
||||
// A surface response provides semantic focus and owns only the actual
|
||||
|
||||
@@ -350,14 +350,13 @@ pub(crate) fn paint_navigation_axes(p: &Painter, rect: Rect, axes: &[NavigatorAx
|
||||
let t = Tokens::get();
|
||||
let c = rect.center();
|
||||
p.circle(c, 46.0 * scale, t.overlay.gamma_multiply(0.35), Stroke::new(scale, t.border.gamma_multiply(0.75)));
|
||||
let mut tips: Vec<_> =
|
||||
axes.iter().map(|axis| (axis, c + crate::view_compass::tip_offset([f64::from(axis.screen.x), f64::from(axis.screen.y)]) * scale)).collect();
|
||||
let mut tips: Vec<_> = axes.iter().map(|axis| (axis, c + axis.screen * (38.0 * 0.84 * scale))).collect();
|
||||
tips.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
|
||||
for (index, (axis, at)) in tips.iter().enumerate() {
|
||||
if index == 3 {
|
||||
p.circle(c, 3.6 * scale, t.text.gamma_multiply(0.9), Stroke::new(1.4 * scale, t.panel));
|
||||
}
|
||||
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, axis.direction, *at, axis.depth, false, t, scale);
|
||||
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, &axis.direction.into(), *at, axis.depth, false, t, scale);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
//! The view compass as a component: the six signed world axes drawn as the camera's view axes
|
||||
//! pose them, a Home chip, and a drag anywhere on it to orbit. It takes plain inputs
|
||||
//! ([`Compass`], [`ViewAxes`]) and returns the [`ViewAction`] chosen, and [`orbit`] returns a drag's
|
||||
//! [`OrbitStep`]; the app turns the camera.
|
||||
//! Every tip publishes its handle (`axis_view:<axis>`) to the widget registry while it is drawn.
|
||||
use crate::icons::navigation::AXIS_RADIUS;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// How far a tip is from the centre, before [`ART_SCALE`].
|
||||
const AXIS_LENGTH: f64 = 38.0;
|
||||
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
|
||||
const ART_SCALE: f64 = 0.84;
|
||||
/// The compass circle's radius: anywhere inside drags the view.
|
||||
const RADIUS: f32 = 49.5;
|
||||
|
||||
/// The six signed world axes, by name.
|
||||
pub const AXES: [(&str, [f64; 3]); 6] = [
|
||||
("x", [1.0, 0.0, 0.0]),
|
||||
("-x", [-1.0, 0.0, 0.0]),
|
||||
("y", [0.0, 1.0, 0.0]),
|
||||
("-y", [0.0, -1.0, 0.0]),
|
||||
("z", [0.0, 0.0, 1.0]),
|
||||
("-z", [0.0, 0.0, -1.0]),
|
||||
];
|
||||
|
||||
/// The camera's view axes in world space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ViewAxes {
|
||||
pub right: [f64; 3],
|
||||
pub up: [f64; 3],
|
||||
/// Toward the viewer.
|
||||
pub back: [f64; 3],
|
||||
}
|
||||
|
||||
impl ViewAxes {
|
||||
/// World direction `d` as the camera poses it: its parts along the screen's right and down,
|
||||
/// and its depth toward the viewer.
|
||||
pub fn pose(&self, d: [f64; 3]) -> ([f64; 2], f64) {
|
||||
([dot(d, self.right), -dot(d, self.up)], dot(d, self.back))
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// One signed axis as drawn: its name (`"x"`, `"-x"`, ... `"-z"`), world direction, tip on screen
|
||||
/// and depth along the camera's back.
|
||||
pub type Tip = (&'static str, [f64; 3], Pos2, f64);
|
||||
|
||||
/// What the user chose on the compass: a tip or Home.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ViewAction {
|
||||
/// Look along an axis: a tip was clicked (`direction`: the tip's world direction).
|
||||
Snap { axis: &'static str, direction: [f64; 3] },
|
||||
/// The Home chip was clicked.
|
||||
Home,
|
||||
}
|
||||
|
||||
/// One frame's step of a drag on a view navigator: `(dx, dy)` screen points (`snap`: Shift was
|
||||
/// held).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct OrbitStep {
|
||||
pub dx: f64,
|
||||
pub dy: f64,
|
||||
pub snap: bool,
|
||||
}
|
||||
|
||||
/// One frame of the compass.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Compass {
|
||||
/// The area it may paint in.
|
||||
pub clip: Rect,
|
||||
pub centre: Pos2,
|
||||
pub axes: ViewAxes,
|
||||
/// The pointer, when it may act on the compass (the app leaves out a pointer over a floating
|
||||
/// window or with the view disabled).
|
||||
pub hover: Option<Pos2>,
|
||||
/// A drag on it is turning the view (no tip lights while it does).
|
||||
pub orbiting: bool,
|
||||
/// The tip's hover text mentions holding Shift to snap.
|
||||
pub snap_hint: bool,
|
||||
pub home_label: String,
|
||||
}
|
||||
|
||||
/// Where a tip is drawn from the centre, for a direction posed on screen as `[right, down]`
|
||||
/// ([`ViewAxes::pose`]); a figure of the compass scales it.
|
||||
pub fn tip_offset([x, y]: [f64; 2]) -> egui::Vec2 {
|
||||
vec2((x * AXIS_LENGTH * ART_SCALE) as f32, (y * AXIS_LENGTH * ART_SCALE) as f32)
|
||||
}
|
||||
|
||||
/// The six signed axes as `axes` poses them around `c`, back to front so overlapping tips have
|
||||
/// one owner.
|
||||
pub fn tips(axes: &ViewAxes, c: Pos2) -> Vec<Tip> {
|
||||
let mut tips: Vec<Tip> = AXES
|
||||
.into_iter()
|
||||
.map(|(name, d)| {
|
||||
let (screen, depth) = axes.pose(d);
|
||||
(name, d, c + tip_offset(screen), depth)
|
||||
})
|
||||
.collect();
|
||||
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
|
||||
tips
|
||||
}
|
||||
|
||||
/// The front-most tip within reach of `p`.
|
||||
pub fn hit(tips: &[Tip], p: Pos2) -> Option<usize> {
|
||||
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Draw the compass; a tip or Home clicked this frame (a drag on it is [`orbit`]'s).
|
||||
pub fn show(ui: &mut egui::Ui, compass: &Compass) -> Option<ViewAction> {
|
||||
let t = Tokens::get();
|
||||
let c = compass.centre;
|
||||
let tips = tips(&compass.axes, c);
|
||||
let hover = compass.hover;
|
||||
let hovered = if compass.orbiting { None } else { hover.and_then(|p| hit(&tips, p)) };
|
||||
let painter = ui.painter_at(compass.clip);
|
||||
let mut action = None;
|
||||
// The circle turns fully opaque under the pointer.
|
||||
let in_circle = hover.is_some_and(|p| p.distance(c) <= RADIUS);
|
||||
let fill = if in_circle || compass.orbiting { 1.0 } else { 0.35 };
|
||||
painter.circle(c, RADIUS, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
|
||||
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
|
||||
if i == 3 {
|
||||
// The origin dot sits behind the three front axes and over the three back ones.
|
||||
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
|
||||
}
|
||||
crate::icons::navigation::axis_tip(&painter, c, name, *d, *at, *depth, hovered == Some(i), t);
|
||||
solidcraft_ui_registry::publish_handle(&format!("axis_view:{name}"), *at);
|
||||
}
|
||||
solidcraft_ui_registry::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
if let Some(i) = hovered
|
||||
&& let Some((name, d, _, _)) = tips.get(i)
|
||||
{
|
||||
solidcraft_ui_registry::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
|
||||
.backing_on_hover_text(format!(
|
||||
"View from {}. Click again to reverse. Drag to orbit{}.",
|
||||
name.to_uppercase(),
|
||||
if compass.snap_hint { "; hold Shift to snap" } else { "" }
|
||||
));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||
action = Some(ViewAction::Snap { axis: name, direction: *d });
|
||||
}
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
|
||||
solidcraft_ui_registry::publish_target("axis_view:home", home);
|
||||
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
|
||||
// The home button sits in its own small round chip.
|
||||
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
|
||||
crate::icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
|
||||
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
|
||||
// chip (the axis ends keep their pointing hand).
|
||||
if hr.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if compass.orbiting {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if in_circle && hovered.is_none() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if hr.backing_on_hover_text(&compass.home_label).clicked() {
|
||||
action = Some(ViewAction::Home);
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// Where a drag on a view navigator stands between frames: the pointer at the last step, and
|
||||
/// where the cursor was warped back to (if it hit the screen's edge).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Drag {
|
||||
pub at: Option<Pos2>,
|
||||
pub warp: Option<Pos2>,
|
||||
}
|
||||
|
||||
/// One frame of a drag on a view navigator: whether it started or ended, whether it is turning
|
||||
/// the view, and this frame's step.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Orbit {
|
||||
/// A press landed on the navigator and holds it.
|
||||
pub grabbed: bool,
|
||||
/// The button was let go of a held navigator.
|
||||
pub released: bool,
|
||||
/// The drag is turning the view.
|
||||
pub orbiting: bool,
|
||||
pub step: Option<OrbitStep>,
|
||||
}
|
||||
|
||||
/// Track a drag on a view navigator. A press may start one (`may_start`: the pointer is over the
|
||||
/// view and free to act) where `starts_at` says the navigator is; once the drag is decided each
|
||||
/// frame's motion is an [`OrbitStep`]. The cube and the custom body share it.
|
||||
pub fn orbit(ui: &egui::Ui, drag: &mut Drag, may_start: bool, starts_at: impl Fn(Pos2) -> bool) -> Orbit {
|
||||
let mut out = Orbit::default();
|
||||
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
|
||||
let p = &i.pointer;
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
|
||||
});
|
||||
if pressed && may_start {
|
||||
drag.at = origin.filter(|p| starts_at(*p));
|
||||
drag.warp = None;
|
||||
out.grabbed = drag.at.is_some();
|
||||
}
|
||||
if !down && drag.at.take().is_some() {
|
||||
drag.warp = None;
|
||||
out.released = true;
|
||||
}
|
||||
let Some(last) = drag.at.filter(|_| decided) else { return out };
|
||||
out.orbiting = true;
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if let Some(p) = pos {
|
||||
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
|
||||
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
|
||||
// it (the echo plus a fast flick): measure each step from whichever reference the
|
||||
// pointer is nearer to, so the view never jumps by the warp distance and a platform
|
||||
// that ignores warps keeps orbiting from the real pointer instead of freezing.
|
||||
let from = match drag.warp {
|
||||
Some(w) if p.distance(w) <= p.distance(last) => {
|
||||
drag.warp = None;
|
||||
w
|
||||
}
|
||||
_ => last,
|
||||
};
|
||||
out.step = Some(OrbitStep { dx: f64::from(p.x - from.x), dy: f64::from(p.y - from.y), snap: shift });
|
||||
drag.at = Some(p);
|
||||
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
|
||||
// orbit toward that edge would stall: warp the cursor back to the window centre and
|
||||
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
|
||||
let screen = ui.ctx().viewport_rect();
|
||||
let edge = 6.0;
|
||||
if drag.warp.is_none()
|
||||
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
|
||||
{
|
||||
let home = screen.center();
|
||||
drag.warp = Some(home);
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, RawInput};
|
||||
|
||||
/// Looking at the front: X to the right, Z up, -Y toward the viewer.
|
||||
const FRONT: ViewAxes = ViewAxes { right: [1.0, 0.0, 0.0], up: [0.0, 0.0, 1.0], back: [0.0, -1.0, 0.0] };
|
||||
|
||||
fn run(ctx: &egui::Context, events: Vec<Event>, hover: Option<Pos2>, drag: &mut Drag) -> (Option<ViewAction>, Orbit) {
|
||||
let mut out = (None, Orbit::default());
|
||||
crate::test_support::frame(
|
||||
ctx,
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused: true, ..Default::default() },
|
||||
|ui| {
|
||||
let orbit = super::orbit(ui, drag, true, |p| p.distance(pos2(200.0, 200.0)) <= RADIUS);
|
||||
let compass = Compass {
|
||||
clip: ui.max_rect(),
|
||||
centre: pos2(200.0, 200.0),
|
||||
axes: FRONT,
|
||||
hover,
|
||||
orbiting: orbit.orbiting,
|
||||
snap_hint: true,
|
||||
home_label: "Home".into(),
|
||||
};
|
||||
out = (show(ui, &compass), orbit);
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn click(ctx: &egui::Context, at: Pos2) -> Option<ViewAction> {
|
||||
let mut drag = Drag::default();
|
||||
run(ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
|
||||
let pressed = run(ctx, crate::test_support::pointer(at, true), Some(at), &mut drag).0;
|
||||
pressed.or(run(ctx, crate::test_support::pointer(at, false), Some(at), &mut drag).0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_tip_or_home_returns_its_view_from_plain_axes() {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let tips = tips(&FRONT, pos2(200.0, 200.0));
|
||||
let x = tips.iter().find(|t| t.0 == "x").map(|t| t.2).unwrap();
|
||||
assert_eq!(x, pos2(200.0 + (38.0 * 0.84) as f32, 200.0), "X points right");
|
||||
assert_eq!(click(&ctx, x), Some(ViewAction::Snap { axis: "x", direction: [1.0, 0.0, 0.0] }));
|
||||
let z = tips.iter().find(|t| t.0 == "z").map(|t| t.2).unwrap();
|
||||
assert_eq!(click(&ctx, z), Some(ViewAction::Snap { axis: "z", direction: [0.0, 0.0, 1.0] }));
|
||||
assert_eq!(click(&ctx, pos2(163.0, 163.0)), Some(ViewAction::Home));
|
||||
assert_eq!(click(&ctx, pos2(20.0, 20.0)), None, "outside the compass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_on_the_compass_returns_orbit_steps_and_ends_on_release() {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut drag = Drag::default();
|
||||
let start = pos2(180.0, 215.0);
|
||||
run(&ctx, vec![Event::PointerMoved(start)], Some(start), &mut drag);
|
||||
let (_, pressed) = run(&ctx, crate::test_support::pointer(start, true), Some(start), &mut drag);
|
||||
assert!(pressed.grabbed && pressed.step.is_none());
|
||||
let mut steps = Vec::new();
|
||||
for x in [190.0, 200.0, 230.0] {
|
||||
let at = pos2(x, 205.0);
|
||||
let (snap, orbit) = run(&ctx, vec![Event::PointerMoved(at)], Some(at), &mut drag);
|
||||
assert_eq!(snap, None, "dragging over a tip does not snap");
|
||||
steps.extend(orbit.step);
|
||||
}
|
||||
let travel = steps.iter().fold((0.0, 0.0, false), |(x, y, held), s| (x + s.dx, y + s.dy, held || s.snap));
|
||||
assert!(!steps.is_empty());
|
||||
assert_eq!(travel, (50.0, -10.0, false), "the steps add up to the pointer's travel once the drag is decided");
|
||||
let end = pos2(230.0, 205.0);
|
||||
let (_, released) = run(&ctx, crate::test_support::pointer(end, false), Some(end), &mut drag);
|
||||
assert!(released.released && drag.at.is_none());
|
||||
}
|
||||
}
|
||||
+127
-52
@@ -7240,58 +7240,99 @@ pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
|
||||
})
|
||||
}
|
||||
|
||||
/// The camera's view axes, as the view compass takes them.
|
||||
fn view_axes(cam: &Camera) -> crate::view_compass::ViewAxes {
|
||||
let (right, up, back) = cam.basis();
|
||||
crate::view_compass::ViewAxes { right: right.into(), up: up.into(), back: back.into() }
|
||||
}
|
||||
const AXIS_LENGTH: f64 = 38.0;
|
||||
/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle.
|
||||
const AXIS_ART_SCALE: f64 = 0.84;
|
||||
use crate::icons::navigation::AXIS_RADIUS;
|
||||
|
||||
/// Six signed world axes, sorted back to front so overlapping tips have one owner (for checks).
|
||||
#[cfg(test)]
|
||||
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<crate::view_compass::Tip> {
|
||||
crate::view_compass::tips(&view_axes(cam), c)
|
||||
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
|
||||
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
|
||||
let (r, u, b) = cam.basis();
|
||||
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
|
||||
.into_iter()
|
||||
.map(|(name, d)| {
|
||||
(name, d, c + vec2((d.dot(r) * AXIS_LENGTH * AXIS_ART_SCALE) as f32, -(d.dot(u) * AXIS_LENGTH * AXIS_ART_SCALE) as f32), d.dot(b))
|
||||
})
|
||||
.collect();
|
||||
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
|
||||
tips
|
||||
}
|
||||
|
||||
/// The six signed world axes as `cam` poses them, for the navigator figure in Preferences: each
|
||||
/// one's screen direction (right and down) and depth along the camera's back.
|
||||
pub(crate) fn navigator_axes(cam: &Camera) -> [crate::ui_kit::NavigatorAxis; 6] {
|
||||
let axes = view_axes(cam);
|
||||
crate::view_compass::AXES.map(|(name, direction)| {
|
||||
let ([x, y], depth) = axes.pose(direction);
|
||||
crate::ui_kit::NavigatorAxis { name, direction, screen: vec2(x as f32, y as f32), depth }
|
||||
})
|
||||
let (right, up, back) = cam.basis();
|
||||
let axis = |name: &'static str, d: Vec3| crate::ui_kit::NavigatorAxis {
|
||||
name,
|
||||
direction: d.into(),
|
||||
screen: vec2(d.dot(right) as f32, -d.dot(up) as f32),
|
||||
depth: d.dot(back),
|
||||
};
|
||||
[axis("x", Vec3::X), axis("-x", -Vec3::X), axis("y", Vec3::Y), axis("-y", -Vec3::Y), axis("z", Vec3::Z), axis("-z", -Vec3::Z)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn axis_hit(tips: &[crate::view_compass::Tip], p: Pos2) -> Option<usize> {
|
||||
crate::view_compass::hit(tips, p)
|
||||
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
|
||||
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Dragging anywhere on the view navigator orbits about the view centre. A stationary press
|
||||
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
|
||||
/// on a tip chooses its view. Floating windows own their input over the gizmo.
|
||||
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
|
||||
let pos = ui.input(|i| i.pointer.interact_pos());
|
||||
let may_start = ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p));
|
||||
let mut drag = crate::view_compass::Drag { at: app.viewport.axis_drag, warp: app.viewport.axis_warp };
|
||||
let orbit = crate::view_compass::orbit(ui, &mut drag, may_start, |p| {
|
||||
let shown = crate::view_gizmo::shown(app);
|
||||
match shown.style {
|
||||
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(p),
|
||||
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, p),
|
||||
crate::prefs::NavigatorStyle::Cube | crate::prefs::NavigatorStyle::Custom => {
|
||||
navigation_cube::drag_origin(app, axis_center(app, rect), p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
|
||||
}
|
||||
}
|
||||
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
|
||||
let p = &i.pointer;
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
|
||||
});
|
||||
(app.viewport.axis_drag, app.viewport.axis_warp) = (drag.at, drag.warp);
|
||||
if orbit.grabbed || orbit.released {
|
||||
if pressed && ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p)) {
|
||||
let shown = crate::view_gizmo::shown(app);
|
||||
app.viewport.axis_drag = origin.filter(|p| match shown.style {
|
||||
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p),
|
||||
crate::prefs::NavigatorStyle::Custom if shown.shape.is_some() => body_gizmo::drag_origin(app, rect, *p),
|
||||
crate::prefs::NavigatorStyle::Cube | crate::prefs::NavigatorStyle::Custom => {
|
||||
navigation_cube::drag_origin(app, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
|
||||
}
|
||||
});
|
||||
app.viewport.axis_warp = None;
|
||||
if app.viewport.axis_drag.is_some() {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
}
|
||||
if !down && app.viewport.axis_drag.take().is_some() {
|
||||
app.viewport.axis_warp = None;
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
if let Some(crate::view_compass::OrbitStep { dx, dy, snap }) = orbit.step {
|
||||
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if let Some(p) = pos {
|
||||
app.cancel_view_animation();
|
||||
orbit_drag(app, rect, dx, dy, None, snap, false);
|
||||
// While a warp is pending the pointer may still sit at the edge it hit (the OS has not
|
||||
// echoed the warp, or cannot), have landed at the centre (the echo), or have moved past
|
||||
// it (the echo plus a fast flick): measure each step from whichever reference the
|
||||
// pointer is nearer to, so the view never jumps by the warp distance and a platform
|
||||
// that ignores warps keeps orbiting from the real pointer instead of freezing.
|
||||
let from = match app.viewport.axis_warp {
|
||||
Some(w) if p.distance(w) <= p.distance(last) => {
|
||||
app.viewport.axis_warp = None;
|
||||
w
|
||||
}
|
||||
_ => last,
|
||||
};
|
||||
orbit_drag(app, rect, f64::from(p.x - from.x), f64::from(p.y - from.y), None, shift, false);
|
||||
app.viewport.axis_drag = Some(p);
|
||||
// The OS stops feeding movement once the cursor is pinned at the screen edge, so an
|
||||
// orbit toward that edge would stall: warp the cursor back to the window centre and
|
||||
// measure the next steps from there, keeping the orbit tracking while the button is held (#9943).
|
||||
let screen = ui.ctx().viewport_rect();
|
||||
let edge = 6.0;
|
||||
if app.viewport.axis_warp.is_none()
|
||||
&& (p.x <= screen.min.x + edge || p.x >= screen.max.x - edge || p.y <= screen.min.y + edge || p.y >= screen.max.y - edge)
|
||||
{
|
||||
let home = screen.center();
|
||||
app.viewport.axis_warp = Some(home);
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(home));
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
orbit.orbiting
|
||||
true
|
||||
}
|
||||
|
||||
/// The whole navigator's box takes the right-click for the navigator's menu, before the view
|
||||
@@ -7339,30 +7380,64 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
}
|
||||
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
|
||||
let t = Tokens::get();
|
||||
let c = axis_center(app, rect);
|
||||
let orbiting = axis_view_orbit(app, ui, rect);
|
||||
let compass = crate::view_compass::Compass {
|
||||
clip: rect,
|
||||
centre: axis_center(app, rect),
|
||||
axes: view_axes(&app.cam),
|
||||
hover: ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)),
|
||||
orbiting,
|
||||
snap_hint: !app.preferences.free_orbit,
|
||||
home_label: solidcraft_ui_text::tr_label("Home view").to_owned(),
|
||||
};
|
||||
// The compass returns a tip or Home; its drag came from `axis_view_orbit` above.
|
||||
match crate::view_compass::show(ui, &compass) {
|
||||
Some(crate::view_compass::ViewAction::Snap { direction, .. }) => {
|
||||
let d = Vec3::from(direction);
|
||||
let frame = app.cam.to_frame(d);
|
||||
let dir = if app.cam.back().dot(d) > 0.9999 { -d } else { d };
|
||||
let tips = axis_tips(&app.cam, c);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p));
|
||||
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
|
||||
let painter = ui.painter_at(rect);
|
||||
// Anywhere in the compass circle drags the view; the circle turns fully opaque under the pointer.
|
||||
let in_circle = hover.is_some_and(|p| p.distance(c) <= 49.5);
|
||||
let fill = if in_circle || orbiting { 1.0 } else { 0.35 };
|
||||
painter.circle(c, 49.5, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75)));
|
||||
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
|
||||
if i == 3 {
|
||||
// The origin dot sits behind the three front axes and over the three back ones.
|
||||
painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel));
|
||||
}
|
||||
crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t);
|
||||
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
|
||||
}
|
||||
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
if let Some(i) = hovered
|
||||
&& let Some((name, d, _, _)) = tips.get(i)
|
||||
{
|
||||
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.scope_id().with("axis_view_tip"), Sense::hover())
|
||||
.backing_on_hover_text(format!(
|
||||
"View from {}. Click again to reverse. Drag to orbit{}.",
|
||||
name.to_uppercase(),
|
||||
if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }
|
||||
));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||
let frame = app.cam.to_frame(*d);
|
||||
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
|
||||
let mut to = app.cam.looking_from(dir);
|
||||
if frame.z.abs() > 0.5 {
|
||||
to.yaw = nearest_quarter_turn(app.cam.yaw);
|
||||
}
|
||||
app.animate_turn(to);
|
||||
}
|
||||
Some(crate::view_compass::ViewAction::Home) => app.animate_view("home"),
|
||||
None => {}
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0));
|
||||
crate::scenario::publish_target("axis_view:home", home);
|
||||
let hr = ui.interact(home, ui.scope_id().with("axis_view_home"), Sense::click());
|
||||
// The home button sits in its own small round chip.
|
||||
painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border));
|
||||
icons::paint_icon(&painter, home, "home", f32::from(t.icon.a()) / 255.0);
|
||||
// Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home
|
||||
// chip (the axis ends keep their pointing hand).
|
||||
if hr.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if orbiting {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if in_circle && hovered.is_none() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if hr.backing_on_hover_text(solidcraft_ui_text::tr_label("Home view")).clicked() {
|
||||
app.animate_view("home");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -685,7 +685,7 @@ fn compass_negative_ends_are_hollow_and_positive_ends_are_chunkier() {
|
||||
let original = c + vec2(38.0, 0.0);
|
||||
let painted = c + (original - c) * 0.84;
|
||||
let output = draw(&ctx, |ui| {
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, false, t);
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, false, t);
|
||||
});
|
||||
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
|
||||
let end = circles.iter().find(|circle| circle.center == painted).unwrap();
|
||||
@@ -929,7 +929,7 @@ fn a_hovered_compass_end_turns_white_and_its_letter_takes_the_axis_colour() {
|
||||
let original = c + vec2(38.0, 0.0);
|
||||
let painted = c + (original - c) * 0.84;
|
||||
let output = draw(&ctx, |ui| {
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), painted, 0.5, true, t);
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, true, t);
|
||||
});
|
||||
let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect();
|
||||
assert!(circles.iter().any(|circle| circle.center == painted && circle.fill == t.on_accent), "{name}: the hovered end is filled white");
|
||||
|
||||
@@ -44,15 +44,6 @@ when you send a message or press **Connect** (or at launch, when you turned that
|
||||
|
||||
## The panel
|
||||
|
||||
With **Preferences → Layout → Use shared panel layout** enabled, side panels keep
|
||||
the conversation above the composer. A wide floating panel places the conversation
|
||||
beside the provider controls and composer when both columns have enough width.
|
||||
Smaller widths keep the stacked layout. The conversation, unsent draft and scroll
|
||||
state stay with the same Assistant when the layout changes. Hovering over its shared
|
||||
header reports the current agent state or pending approvals; requests that need
|
||||
attention use the shared attention indication. Classic and the Assistant's own
|
||||
native window keep their existing layout.
|
||||
|
||||
In the floating layout, Assistant fills the viewport height in the shared rounded panel, with
|
||||
space around its edges. The compass and navigation bar move into the remaining space beside it. It overlays the model without changing the viewport projection;
|
||||
clicks and scrolling over the panel stay in the chat. The docked layout retains its side panel.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
Cut a flat bevel along edges of a solid: the "break all edges" or "0.03 × 45°" callout on a print. You pick the edges and how the chamfer is sized.
|
||||
|
||||
#### Before you start
|
||||
|
||||
- You need a solid body with edges to chamfer.
|
||||
- Pick edges one at a time, or click a face to chamfer every edge around it.
|
||||
- Chamfer edges before you add other features that touch them. An edge that already meets a small chamfer or fillet has little room left, and a chamfer there can fail.
|
||||
|
||||
#### Options
|
||||
|
||||
| Choice in the dialog | Meaning | Example or reason to choose it |
|
||||
| --- | --- | --- |
|
||||
| Edges | The edges to chamfer. Click edges one by one, or click a face to take all of its edges. | The four top edges of a block, or the top face. |
|
||||
| Chamfer Type | Equal Distance: the same leg on both faces. That is a 45° chamfer only where the faces meet at 90°. Two Distances: a different leg on each face. Distance and Angle: one leg and an angle. | Equal Distance for a standard edge break on a square corner. Distance and Angle whenever the print gives an angle, like 0.06 × 30°, and for any chamfer on a sloped face. |
|
||||
| Distance (Equal Distance) | The leg length on both faces. | 0.03 in for a typical edge break. |
|
||||
| Distance 1 and Distance 2 (Two Distances) | The leg on each face. | 0.06 in by 0.03 in. |
|
||||
| Distance 1 and Angle (Distance and Angle) | The leg on the first face, and the angle off that face. | 0.06 in at 45°. |
|
||||
| Flip | Swaps which face the first distance is measured on. Shown for Two Distances and Distance and Angle. | Turn it on if the long leg ends up on the wrong face. |
|
||||
| Tangent chain | Follows the edge around smooth (tangent) bends, such as a fillet, so one pick chamfers the whole run. | Leave it on for edges that flow into a fillet. |
|
||||
| Preview | Shows the chamfer on the part while you set it up. | Leave it on. |
|
||||
|
||||
#### Try it: break the top edges of a plate
|
||||
|
||||
1. Make a 3 in × 2 in × 0.75 in block (sketch a rectangle, Extrude 0.75 in).
|
||||
2. Click Chamfer and click the four top edges. Edges shows 4 selected.
|
||||
3. Set Chamfer Type to Equal Distance and Distance to 0.03 in.
|
||||
4. Click OK. All four top edges have a 0.03 × 45° chamfer.
|
||||
5. To change it, edit the chamfer and set Chamfer Type to Distance and Angle, Distance 1 to 0.06 in and Angle to 30. Use Flip to swap which face takes the 0.06 in leg.
|
||||
|
||||
#### Tips and common mistakes
|
||||
|
||||
- **Do** type the unit with the number when the print uses another unit. In a millimetre design, typing 0.03 in enters 0.762 mm.
|
||||
- **Do** chamfer all the edges of one size in a single Chamfer. It's one feature to change when the print changes.
|
||||
- **Do** pick the edges one by one for Two Distances or Distance and Angle. Picking a whole face works with Equal Distance, but the other two types can stop with "not supported yet: chamfers ending at a concave corner".
|
||||
- **Don't** chamfer an edge right next to an existing small chamfer with a larger size. If the chamfer would run past the neighbouring face, the dialog says so; make the chamfer smaller or chamfer those edges together in one feature.
|
||||
|
||||
#### See also
|
||||
|
||||
{{#command solid.hole}} adds a countersink at the top of a hole. {{#command solid.fillet}} rounds an edge instead of beveling it.
|
||||
@@ -10,22 +10,19 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
|
||||
|
||||
| Choice in the dialog | Meaning | Example or reason to choose it |
|
||||
| --- | --- | --- |
|
||||
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Presets are saved in your profile, so they carry over to every design. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
|
||||
| Personal Presets | Saves the dialog settings under a name you choose (Save new), so you can reuse them. Rename and Delete manage saved presets. | Save a "1/4-20 blind" preset once and pick it for every tapped hole of that size. |
|
||||
| Placement | Single drills one hole on the face you pick. Multiple drills a hole at each sketch point you select. | Single for one hole located from edges. Multiple for a bolt pattern laid out in a sketch. |
|
||||
| Face | The face the hole is drilled into. | Click the top face of the part. |
|
||||
| Reference 1 and Distance 1 | An edge of the face, and how far the hole center is from it. | The left edge, 0.75 in. |
|
||||
| Reference 2 and Distance 2 | A second edge, and how far the hole center is from it. | The front edge, 0.75 in. |
|
||||
| Extents | How the depth is set. Distance drills to the Depth you type. | Use Distance for a blind hole. |
|
||||
| Hole Type | Simple is a straight hole. Counterbore and Countersink add a bore or a cone at the top. | Counterbore for a socket head cap screw. Countersink for a flat head screw: 82° for inch screws, 90° for metric. |
|
||||
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and shows the thread settings. | Choose Tapped for any tapped hole. |
|
||||
| Hole Tap Type | Simple is a plain drilled hole with a Diameter you type. Tapped drills the tap drill size and carries the thread as a callout; the thread itself is not cut into the hole wall. | Choose Tapped for any tapped hole. To show real thread geometry, use {{#command solid.thread}} on the hole wall. |
|
||||
| Profile (Tapped) | The thread standard. | UNC for coarse inch threads, UNF for fine inch threads. |
|
||||
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout. | 1/4-20 UNC. |
|
||||
| Tap drill diameter (Tapped) | The drill for the tapped hole. It fills in the standard tap drill for the size, the one on a tap drill chart (the app's messages call it "the tap maker's drill"), and you can type a different one. The app accepts drills from the smaller of the thread's minor diameter and the standard tap drill, up to 5% over the standard tap drill, and says the allowed range if you go outside it. For a custom metric size with no entry in the app's tap drill table, the app fills in major diameter minus pitch, the usual rule of thumb; check a tap drill chart. | 1/4-20 UNC fills in 5.1054 mm (0.201 in, a #7 drill). Typing 0.203 in (13/64) is accepted; 0.220 in is refused because too little thread would engage. |
|
||||
| Size (Tapped) | The thread size, from a list. The Designation box shows the callout, and Tap drill diameter shows the drill the app uses. | 1/4-20 UNC uses a 0.201 in (#7) tap drill. The app shows it as 5.1054 mm. |
|
||||
| Full thread depth (Tapped) | On: the thread runs the full depth of the hole. Off: a Thread depth box appears. | Turn it off for a blind tapped hole. |
|
||||
| Thread depth (Tapped) | How deep the full thread goes, separate from the drill Depth. | Drill 0.625 in deep with 0.50 in of thread, so a standard tap doesn't bottom out. |
|
||||
| Hand (Tapped) | Right or Left. | Right unless the print calls for LH. |
|
||||
| Modeled (Tapped) | On: the thread groove is cut into the hole wall. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
|
||||
| End (Modeled on) | Square stops the thread sharp at the bottom. Fade runs it out over a length, the way a tap's chamfered end leaves it. | Fade for a realistic blind tapped hole. |
|
||||
| Thread direction (Tapped) | Right-hand or left-hand thread. | Right-hand unless the print calls for LH. |
|
||||
| Drill Point | The bottom of the hole. Angle leaves a drill point. | Use Angle for a hole made with a twist drill. |
|
||||
| Depth | How deep the hole goes. | Measured to the end of the full diameter, not the tip of the drill point. |
|
||||
| Tip Angle | The angle of the drill point. Default is 118°. | 118° for a standard jobber drill. 135° for most carbide and split-point drills. |
|
||||
@@ -39,13 +36,11 @@ Put a hole in a solid body: a simple hole, a counterbore, a countersink or a tap
|
||||
4. Click Reference 1, pick the left edge of the top face and set Distance 1 to 0.75 in. Click Reference 2, pick the front edge and set Distance 2 to 0.75 in.
|
||||
5. Set Hole Tap Type to Tapped, Profile to UNC and Size to 1/4-20 UNC. Check that Tap drill diameter shows 5.1054 mm (0.201 in).
|
||||
6. Turn off Full thread depth. Set Depth to 0.625 in and Thread depth to 0.50 in. Click OK.
|
||||
|
||||
#### Tips and common mistakes
|
||||
|
||||
- **Do** locate holes from edges the way the print dimensions them. When the part changes size, the holes keep their distances from those edges.
|
||||
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from a standard tap drill chart.
|
||||
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in. If your shop runs a different drill, such as 0.203 in for 1/4-20 in steel, type it in.
|
||||
- **Don't** turn on Modeled just to show the hole is tapped. The callout already says it.
|
||||
- **Do** use Tapped and pick the size from the list instead of typing a drill diameter. The hole then carries the thread callout, and the tap drill comes from the tap maker's table.
|
||||
- **Do** check the Tap drill diameter before clicking OK. It is shown in millimetres: 5.1054 mm is 0.201 in.
|
||||
- **Do** turn off Full thread depth on blind tapped holes and set a Thread depth shorter than the drill Depth, so a standard tap doesn't bottom out.
|
||||
- **Do** type the unit with the number when the print is in another unit. In a millimetre design, typing 0.75 in enters 19.05 mm.
|
||||
- **Don't** expect every hole to work on sheet metal. Sheet metal holes must go straight into a flat panel; holes into the edge of the sheet, countersinks and drill points are refused there.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
Put a thread on a round face: an external thread on a shaft or stud, or an internal thread in a bore. You pick the thread form and size, how far the thread runs, and how its ends are finished.
|
||||
|
||||
#### Before you start
|
||||
|
||||
- You need a body with a cylindrical face: the outside of a shaft for an external thread, or the wall of a bore for an internal one.
|
||||
- Size the cylinder first. For an external thread, make the shaft the thread's major diameter (12 mm for M12). For an internal thread, make the bore the tap drill size.
|
||||
- Click the face near the end you want threaded. On a shaft with two free ends, that tells a limited-length thread where to start.
|
||||
- For an ordinary tapped hole, including UNC and UNF, use {{#command solid.hole}} with Hole Tap Type set to Tapped instead. It drills the tap drill and keeps the thread callout on the hole.
|
||||
|
||||
#### Options
|
||||
|
||||
| Choice in the dialog | Meaning | Example or reason to choose it |
|
||||
| --- | --- | --- |
|
||||
| Cylindrical face | The round face the thread goes on. | Click the outside of the shaft near the end to thread. |
|
||||
| Form | The thread standard: ISO metric, Trapezoidal (ISO 2904), Acme (ASME B1.5), Buttress (DIN 513 or ASME B1.9), and the pipe threads Pipe G (ISO 228-1), Pipe Rp (ISO 7-1), Pipe taper R, Rc (ISO 7-1) and Pipe taper NPT (ASME B1.20.1). UNC and UNF are not in this list. | ISO metric for fasteners. Acme or Trapezoidal for lead screws. Buttress for a screw that takes load one way, like a vise screw. Pipe taper NPT for US tapered pipe threads. |
|
||||
| Modeled | On: the thread groove is cut into the part. Off: the thread is carried as a callout only. | Leave it off unless you need to see the thread or 3D print it. A modeled thread makes the part slower to rebuild. |
|
||||
| Automatic size | On by default: picks the standard size that fits the face you selected. | Leave it on when the shaft is a standard diameter. |
|
||||
| Size and Pitch | The thread size from the standard's table, and for metric threads the pitch. | M12 with the standard 1.75 mm pitch, or 1.25 mm for M12 fine. |
|
||||
| Designation | The thread callout. You can type it directly. | M12, M12x1.25. |
|
||||
| Hand | Right, Left, or Both. Pipe threads offer Right or Left only. | Right unless the print calls for LH. |
|
||||
| Starts | Number of thread starts, 1 to 8. | 1 for fasteners. 2 or more for a fast-moving lead screw: each turn advances the pitch times the number of starts. |
|
||||
| Steep Flank (Buttress only) | Which end the steep, load-carrying flank faces. | Point it toward the load. |
|
||||
| Extent | Full runs the whole face. Length runs a set distance. Up to stops at a face or plane you pick. | Length for a threaded end on a longer shaft. Up to for a thread that stops at a shoulder. |
|
||||
| Start end (optional) | Which end of the face the thread is measured from. Shown once Extent is Length or Up to. | Pick it when the thread starts at the wrong end. |
|
||||
| Start (Ends) | None leaves the thread's free end plain. Chamfer adds a lead-in chamfer and shows Chamfer Angle and Chamfer Depth; left blank, they use the ISO 4753 standard chamfer. | Chamfer the start of any thread that has to take a nut or go into a tapped hole by hand. |
|
||||
| End (Ends) | Square stops the thread sharp. Fade runs it out over a length (ISO 3508 runout, 2.5 pitches standard). | Fade for a thread cut with a die or single-point tool that stops short of a shoulder. |
|
||||
|
||||
#### Try it: a threaded shaft end
|
||||
|
||||
1. Sketch a 12 mm circle on the XY plane and extrude it 50 mm.
|
||||
2. Click Thread, then click the round face of the shaft near its top end.
|
||||
3. Check that Form is ISO metric and Automatic size picked M12 with a 1.75 mm pitch.
|
||||
4. Set Extent to Length and the length to 20 mm.
|
||||
5. Turn on Modeled to see the thread cut into the shaft.
|
||||
6. Under Ends, set Start to Chamfer. Leave Chamfer Angle and Chamfer Depth blank for the standard ISO 4753 chamfer.
|
||||
7. Click OK. The top 20 mm of the shaft carries an M12 thread with a lead-in chamfer.
|
||||
|
||||
#### Tips and common mistakes
|
||||
|
||||
- **Do** size the cylinder before threading it: major diameter for an external thread, tap drill size for an internal one.
|
||||
- **Do** click the face near the end you want threaded, or pick Start end. Otherwise a Length thread can start at the wrong end.
|
||||
- **Do** chamfer the start of an external thread. The shop will put one there anyway, and a nut starts on it in the model the same way it does on the part.
|
||||
- **Do** leave room for runout before a shoulder. A thread can't run full depth right up to a shoulder without a relief groove.
|
||||
- **Don't** turn on Modeled just to show the part is threaded. The callout already says it.
|
||||
- **Don't** look for UNC or UNF here. Inch fastener threads are only on {{#command solid.hole}} tapped holes for now; the inch forms in this list are Acme, ASME buttress and the pipe threads.
|
||||
- **Don't** mix up NPT and the ISO taper (R, Rc, often called BSPT). Both are tapered pipe threads, but the thread angle and pitch differ, and they don't seal together.
|
||||
|
||||
#### See also
|
||||
|
||||
{{#command solid.hole}} makes tapped holes with the right tap drill, including UNC and UNF. {{#command solid.chamfer}} breaks an edge by hand. {{#command solid.revolve}} makes a turned part to thread.
|
||||
@@ -85,7 +85,7 @@ origin; the design itself does not move. The face is tinted and outlined in the
|
||||
copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is
|
||||
reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces.
|
||||
Known limitation: after a shell of a body that is already shelled, no face of that body can be
|
||||
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
|
||||
marked, as no sketch can be attached to one either; this needs kernel work, which is paused.
|
||||
|
||||
The list also has a plain **EasyPrint (Printables, in the browser)** row, and it is the one entry
|
||||
that works in the browser build too. EasyPrint is Printables' slicer, so choosing it uploads the
|
||||
|
||||
@@ -26,14 +26,6 @@ Browser/History uses the existing content preference and suppresses the separate
|
||||
History body. Render Inspector, Shots and appearances keep their existing hosts
|
||||
and contribute their actual footprints to navigation and camera fitting.
|
||||
|
||||
Assistant uses the shared flow and actual content width. Tall content keeps the
|
||||
conversation above its pinned composer. A viable Wide presentation puts the
|
||||
conversation beside the existing provider controls and composer; smaller widths
|
||||
retain the stacked presentation. Both use the same content identities, draft,
|
||||
scroll state and backend. Shared header status on hover and its attention indication
|
||||
come from the existing Assistant state and pending approvals. Classic and native
|
||||
Assistant composition is unchanged.
|
||||
|
||||
Visible bodies report their natural scroll content and fixed control bands.
|
||||
A sole production panel hugs that content until column capacity requires scrolling;
|
||||
Assistant declares Fill to use the full available side height. A newly visible unmeasured
|
||||
@@ -58,7 +50,7 @@ Each section contains ordered tabs. Inactive tabs still constrain composite size
|
||||
limits. Registrations declare allowed zones, PinOuter, Content/Fill height policy,
|
||||
text-heavy content, surface preference, header actions and independent
|
||||
`closable`/`hot_closable` flags. No generic host branches on product panel names.
|
||||
Only the History product registration allows Bottom; synthetic kinds can allow
|
||||
Only future History product registration allows Bottom; synthetic kinds can allow
|
||||
all zones. Quick close is an affordance, not the close capability itself.
|
||||
|
||||
Layout is application/workspace preference data. It never enters CAD files,
|
||||
|
||||
@@ -413,7 +413,7 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
- Print faces (#8429) are held like sketches on faces, so the same limit applies: after a shell
|
||||
of a body that is already shelled, no face of that body has an exact name, and none can be
|
||||
marked (`body.print_face` says so). Fixing it needs the shell to report its face history,
|
||||
which is kernel work, done in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
which is kernel work, paused for now (CONTRIBUTING.md, "Kernel work is paused").
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
// While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
|
||||
// pull requests that change the kernel directories, get the `kernel-paused` label, so a maintainer
|
||||
// finds them and answers in person (CONTRIBUTING.md, "Kernel work is paused"). Reports of crashes
|
||||
// and data loss are left alone, and so is anyone who can push to the repository.
|
||||
|
||||
const LABEL = 'kernel-paused';
|
||||
const LABEL_COLOR = 'b60205';
|
||||
const LABEL_DESCRIPTION = 'Kernel work is paused while a new geometry kernel is evaluated';
|
||||
|
||||
// Kernel operations named in the title of an issue or discussion. "Hole" and "shell" are left out
|
||||
// on purpose: they name UI work too. A pull request is judged by its files, never by its title.
|
||||
const TOPIC =
|
||||
/\b(booleans?|fillets?|chamfers?|offsets?|sweeps?|lofts?|threads?|step|stp|iges|igs|tessellat\w*|kernel|truck|b-?rep)\b/i;
|
||||
// Crashes and lost work come first whatever the kernel is.
|
||||
const URGENT = /\b(crash\w*|panic\w*|data loss|lost (my )?work|corrupt\w*)\b/i;
|
||||
// What a pull request changes that makes it kernel work.
|
||||
const KERNEL_PATHS = ['crates/kernel/', 'crates/geom/', 'solidcore/'];
|
||||
|
||||
function isTopic(title) {
|
||||
return TOPIC.test(title || '');
|
||||
}
|
||||
|
||||
function isUrgent(title, body) {
|
||||
return URGENT.test(`${title || ''}\n${body || ''}`);
|
||||
}
|
||||
|
||||
function touchesKernel(files) {
|
||||
return files.some((f) => KERNEL_PATHS.some((p) => f.startsWith(p)));
|
||||
}
|
||||
|
||||
// Whether the author can push to the repository (maintainers and their agents are not paused).
|
||||
async function canPush({ github, context }, username) {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username });
|
||||
return ['admin', 'maintain', 'write'].includes(data.permission);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLabel({ github, context }) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...context.repo, name: LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
await github.rest.issues.createLabel({ ...context.repo, name: LABEL, color: LABEL_COLOR, description: LABEL_DESCRIPTION });
|
||||
}
|
||||
}
|
||||
|
||||
// A new issue or pull request: returns what was done, for the log.
|
||||
async function handleOpened({ github, context }) {
|
||||
const item = context.payload.pull_request || context.payload.issue;
|
||||
if (!item) return 'nothing to do';
|
||||
const pull = Boolean(context.payload.pull_request);
|
||||
if (isUrgent(item.title, item.body)) return `#${item.number}: a crash or data-loss report, left alone`;
|
||||
if (await canPush({ github, context }, item.user.login)) return `#${item.number}: by a maintainer, left alone`;
|
||||
let kernel;
|
||||
if (pull) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: item.number, per_page: 100 });
|
||||
kernel = touchesKernel(files.map((f) => f.filename));
|
||||
} else {
|
||||
kernel = isTopic(item.title);
|
||||
}
|
||||
if (!kernel) return `#${item.number}: not kernel work`;
|
||||
await ensureLabel({ github, context });
|
||||
await github.rest.issues.addLabels({ ...context.repo, issue_number: item.number, labels: [LABEL] });
|
||||
return `#${item.number}: labelled ${LABEL}`;
|
||||
}
|
||||
|
||||
// A new discussion.
|
||||
async function handleDiscussion({ github, context }) {
|
||||
const d = context.payload.discussion;
|
||||
if (!d) return 'nothing to do';
|
||||
if (!isTopic(d.title) || isUrgent(d.title, d.body)) return `discussion #${d.number}: left alone`;
|
||||
if (await canPush({ github, context }, d.user.login)) return `discussion #${d.number}: by a maintainer, left alone`;
|
||||
await ensureLabel({ github, context });
|
||||
const { repository } = await github.graphql(
|
||||
'query($owner: String!, $repo: String!, $name: String!) { repository(owner: $owner, name: $repo) { label(name: $name) { id } } }',
|
||||
{ ...context.repo, name: LABEL },
|
||||
);
|
||||
if (repository && repository.label) {
|
||||
await github.graphql('mutation($id: ID!, $labels: [ID!]!) { addLabelsToLabelable(input: {labelableId: $id, labelIds: $labels}) { clientMutationId } }', {
|
||||
id: d.node_id,
|
||||
labels: [repository.label.id],
|
||||
});
|
||||
}
|
||||
return `discussion #${d.number}: labelled ${LABEL}`;
|
||||
}
|
||||
|
||||
module.exports = { LABEL, isTopic, isUrgent, touchesKernel, handleOpened, handleDiscussion };
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const paused = require('./kernel-paused.js');
|
||||
|
||||
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
|
||||
|
||||
// A fake github-script client that records writes. `world` describes the repo.
|
||||
function fake(world = {}) {
|
||||
const calls = [];
|
||||
const rec = (name) => async (args) => {
|
||||
calls.push([name, args]);
|
||||
return { data: {} };
|
||||
};
|
||||
const github = {
|
||||
paginate: async (fn, args) => (await fn(args)).data,
|
||||
graphql: async (query, args) => {
|
||||
calls.push([query.trim().split(/[\s({]/)[0] === 'query' ? 'query' : query.match(/\{\s*(\w+)/)[1], args]);
|
||||
return { repository: { label: { id: 'L1' } } };
|
||||
},
|
||||
rest: {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({ data: { permission: (world.perms || {})[username] || 'read' } }),
|
||||
},
|
||||
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
|
||||
issues: {
|
||||
getLabel: async () => {
|
||||
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
|
||||
return { data: {} };
|
||||
},
|
||||
createLabel: rec('createLabel'),
|
||||
addLabels: rec('addLabels'),
|
||||
createComment: rec('comment'),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { github, calls };
|
||||
}
|
||||
|
||||
const names = (calls) => calls.map((c) => c[0]);
|
||||
|
||||
test('kernel topics are named in titles; hole and shell are not', () => {
|
||||
for (const t of ['Fillet fails on a cone', 'Booleans: cut leaves a sliver', 'STEP import drops a face', 'Tessellation hangs']) {
|
||||
assert.ok(paused.isTopic(t), t);
|
||||
}
|
||||
for (const t of ['Hole dialog: diameter field', 'Shell command icon', 'Sketch: dimension text', 'Stepper widget']) {
|
||||
assert.ok(!paused.isTopic(t), t);
|
||||
}
|
||||
});
|
||||
|
||||
test('a kernel issue is labelled, nothing more', async () => {
|
||||
const { github, calls } = fake({ labelMissing: true });
|
||||
const context = { repo, payload: { issue: { number: 5, title: 'Chamfer on a spline edge fails', body: '', user: { login: 'someone' } } } };
|
||||
await paused.handleOpened({ github, context });
|
||||
assert.deepEqual(names(calls), ['createLabel', 'addLabels']);
|
||||
});
|
||||
|
||||
test('crashes, data loss and maintainers are left alone', async () => {
|
||||
for (const body of ['The app crashed when I filleted this', 'I lost my work after a boolean']) {
|
||||
const { github, calls } = fake();
|
||||
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 6, title: 'Fillet', body, user: { login: 'someone' } } } } });
|
||||
assert.deepEqual(calls, [], body);
|
||||
}
|
||||
const { github, calls } = fake({ perms: { owner: 'admin' } });
|
||||
await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 7, title: 'Fillet', body: '', user: { login: 'owner' } } } } });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a pull request that changes the kernel is labelled, whatever its title', async () => {
|
||||
const { github, calls } = fake({ files: ['docs/a.md', 'crates/kernel/src/blend.rs'] });
|
||||
const pr = { number: 8, title: 'Speed up a hot path', body: '', draft: false, node_id: 'PR_1', user: { login: 'someone' } };
|
||||
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
|
||||
assert.deepEqual(names(calls), ['addLabels']);
|
||||
});
|
||||
|
||||
test('a pull request outside the kernel is left alone', async () => {
|
||||
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
|
||||
const pr = { number: 9, title: 'Dialog spacing', body: '', draft: false, node_id: 'PR_2', user: { login: 'someone' } };
|
||||
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a pull request with a kernel topic in its title but no kernel files is left alone', async () => {
|
||||
const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] });
|
||||
const pr = { number: 10, title: 'Fillet dialog: a clearer radius field', body: '', draft: false, node_id: 'PR_3', user: { login: 'someone' } };
|
||||
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('a pull request that changes crates/geom is labelled', async () => {
|
||||
const { github, calls } = fake({ files: ['crates/geom/src/lib.rs'] });
|
||||
const pr = { number: 11, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_4', user: { login: 'someone' } };
|
||||
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
|
||||
assert.deepEqual(names(calls), ['addLabels']);
|
||||
});
|
||||
|
||||
test('a pull request that changes the vendored kernel is labelled', async () => {
|
||||
const { github, calls } = fake({ files: ['solidcore/crates/solidcore/src/blend.rs'] });
|
||||
const pr = { number: 12, title: 'Tidy a helper', body: '', draft: false, node_id: 'PR_5', user: { login: 'someone' } };
|
||||
await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } });
|
||||
assert.deepEqual(names(calls), ['addLabels']);
|
||||
});
|
||||
|
||||
test('a kernel discussion is labelled', async () => {
|
||||
const { github, calls } = fake();
|
||||
const d = { number: 3, title: 'Ideas for better booleans', body: '', node_id: 'D_1', user: { login: 'someone' } };
|
||||
await paused.handleDiscussion({ github, context: { repo, payload: { discussion: d } } });
|
||||
assert.deepEqual(names(calls), ['query', 'addLabelsToLabelable']);
|
||||
});
|
||||
@@ -54,17 +54,12 @@ function parseDefaults(body) {
|
||||
if (pref && pref[1].trim() && !/^<.*>$/.test(pref[2])) {
|
||||
return { status: 'yes', detail: pref[1].trim(), preference: pref[2] };
|
||||
}
|
||||
// solidcore's rule is the opposite of the app's: a kernel result change becomes the default and the
|
||||
// old path is deleted, no flag (solidcore/AGENTS.md §4b). Accepted only for PRs that stay in solidcore/.
|
||||
const kernel = yes[1].match(/^(.*?)[ ,;]*\bkernel result change\b[ ,;]*no (?:preference|flag)\.?$/i);
|
||||
if (kernel && kernel[1].trim()) return { status: 'kernel', detail: kernel[1].trim() };
|
||||
}
|
||||
return { status: 'malformed', detail: value };
|
||||
}
|
||||
|
||||
const DEFAULTS_HELP =
|
||||
'Add one line to the description: `Changes a default: no`, or ' +
|
||||
'(solidcore/ only) `Changes a default: yes — <what changes>; kernel result change, no preference`, or ' +
|
||||
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`.';
|
||||
|
||||
function isBot(user) {
|
||||
@@ -156,10 +151,7 @@ async function checkDefaults({ github, context, core }) {
|
||||
})).map((f) => f.filename);
|
||||
if (isDocsOnly(files)) return { skipped: 'docs only' };
|
||||
const result = parseDefaults(pull.body);
|
||||
if (result.status === 'kernel' && !files.every((f) => f.startsWith('solidcore/') || DOC_FILE.test(f))) {
|
||||
core.setFailed('"kernel result change, no preference" is only for pull requests that change solidcore/ alone; ' +
|
||||
`a change to the app keeps its old behaviour behind a preference. ${DEFAULTS_HELP}`);
|
||||
} else if (result.status === 'missing') {
|
||||
if (result.status === 'missing') {
|
||||
core.setFailed(`The description has no "Changes a default:" line. ${DEFAULTS_HELP}`);
|
||||
} else if (result.status === 'malformed') {
|
||||
core.setFailed(`Could not read "Changes a default: ${result.detail}". ${DEFAULTS_HELP}`);
|
||||
|
||||
@@ -47,13 +47,6 @@ test('parseDefaults yes forms', () => {
|
||||
assert.equal(pc.parseDefaults('Changes a default: yes — x, old behaviour kept behind preference <name>').status, 'malformed');
|
||||
});
|
||||
|
||||
test('parseDefaults kernel result change (solidcore/ only)', () => {
|
||||
const r = pc.parseDefaults('Changes a default: yes — the fillet corner patch is G2 now; kernel result change, no preference');
|
||||
assert.equal(r.status, 'kernel');
|
||||
assert.equal(r.detail, 'the fillet corner patch is G2 now');
|
||||
assert.equal(pc.parseDefaults('Changes a default: yes - kernel result change, no preference').status, 'malformed');
|
||||
});
|
||||
|
||||
test('isBot and isDocsOnly', () => {
|
||||
assert.ok(pc.isBot({ login: 'dependabot[bot]', type: 'Bot' }));
|
||||
assert.ok(pc.isBot({ login: 'solidcraft-generated[bot]' }));
|
||||
|
||||
@@ -72,7 +72,7 @@ not modelling a part through MCP.
|
||||
operation protocol in `plan/execution-plan.md` section 6; do not stop to ask unless it lists the decision
|
||||
as the owner's. Contributors without `plan/` can ignore this.
|
||||
- **Update the roadmap** when a milestone task lands: `docs/roadmap-detail.md` tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap.
|
||||
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work goes in `solidcore/` ("Kernel work goes in solidcore/" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
|
||||
- **Start here.** What to work on: `ROADMAP.md`, "Where help is wanted most" in CONTRIBUTING.md and the open issues labelled `help wanted`, `good first issue`, `needs-triage`; check what is already open first. Architecture: `docs/architecture.md` and "How the code is laid out" in CONTRIBUTING.md; kernel work is paused ("Kernel work is paused" in CONTRIBUTING.md). Licence rules: `deny.toml` and `cargo xtask licences --check`; only `crates/smlib` may use `solidcore`. When the code, docs and rules settle a question, act; ask on the issue or PR only when the answer changes what users see or what the change covers.
|
||||
- **Do not open a second PR** for an issue a lower-numbered open PR already closes, and keep the `Changes a default:` line filled in: `pr-checks.yml` fails both.
|
||||
- **The gate reads the working tree.** Do not switch branches while `cargo xtask ci` runs: it compiles the other branch's code and fails on a change not in the PR. Run it in the background and wait for its log.
|
||||
|
||||
@@ -95,11 +95,6 @@ not modelling a part through MCP.
|
||||
- **Do not parallelize what depends on itself.** A fix and the test that proves it, a
|
||||
refactor and the callers it breaks, two edits to the same file: those stay sequential.
|
||||
One subagent per independent unit of work, and no two units may write the same file.
|
||||
- **Keep the cost down.** Keep one long-running agent session instead of restarting it.
|
||||
Don't `--resume` a session after its prompt cache has gone cold: a cold resume re-reads the
|
||||
whole history at full price. Act on CodeRabbit comments only when they're about crashes,
|
||||
wrong results, data loss or security, and ignore style nitpicks. Don't push just to
|
||||
re-trigger a review.
|
||||
|
||||
## 1. Check the environment
|
||||
|
||||
@@ -158,14 +153,15 @@ fails, inspect the divergence and preserve existing work; do not reset it away.
|
||||
## 3. Choose an unclaimed task together
|
||||
|
||||
Ask about their interests and show a few issues that fit the available resources.
|
||||
Kernel and geometry tasks are fine: they go in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
Read the current kernel-pause notice in CONTRIBUTING.md and skip kernel work
|
||||
while paused, including tasks outside `crates/kernel/` that change kernel behaviour.
|
||||
Fetch the whole open list, including drafts, before narrowing it:
|
||||
|
||||
```sh
|
||||
gh issue list -R tangency-labs/solidcraft --state open --limit 500
|
||||
gh pr list -R tangency-labs/solidcraft --state open --limit 500 --json number,title,files
|
||||
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:"in progress"' --limit 100
|
||||
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:"in progress"' --limit 100
|
||||
gh issue list -R tangency-labs/solidcraft --state open --label 'good first issue' --search '-label:kernel-paused -label:"in progress"' --limit 100
|
||||
gh issue list -R tangency-labs/solidcraft --state open --label 'help wanted' --search '-label:kernel-paused -label:"in progress"' --limit 100
|
||||
```
|
||||
|
||||
Let the person choose. Set `N` to that issue's number, then inspect its discussion,
|
||||
|
||||
@@ -27,4 +27,4 @@ The desktop app writes every panic and every unclean exit to a local crash folde
|
||||
- `solidcraft-cli crashes`, `solidcraft-cli crashes show <id>`, `solidcraft-cli crashes replay <id>`
|
||||
- the `diagnostics.crash_reports` command, or the MCP `crash_reports` tool
|
||||
|
||||
Crashes and data loss are taken first, including in kernel code, which lives in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
Crashes and data loss are taken as issues even while kernel work is paused (CONTRIBUTING.md, "Kernel work is paused").
|
||||
|
||||
@@ -21,7 +21,7 @@ Drawings follow the same rules with their own numbers, from 1: a drawing-format
|
||||
- Imported files are untrusted: sizes, entity counts and nesting are capped, and every kernel call is guarded (`skills/solidcraft-crash-safety/SKILL.md`). What cannot be read becomes a warning on the feature.
|
||||
- STEP is read by our own reader in `crates/kernel/src/step_in`, written from the public standards, with no third-party STEP code. The reader and what it supports: `docs/step-import.md`.
|
||||
- Imported assemblies are rigid by default; Unlock Contents releases them.
|
||||
- Kernel work (booleans, STEP/IGES import and export, tessellation) goes in `solidcore/`: CONTRIBUTING.md, "Kernel work goes in solidcore/".
|
||||
- Kernel work (booleans, STEP/IGES import and export, tessellation) is paused except crashes and data loss: CONTRIBUTING.md, "Kernel work is paused".
|
||||
|
||||
## Export
|
||||
- STEP, 3MF, STL, OBJ and URDF go through `file.export` and `file.save_mesh`; the MCP `export` tool calls them. URDF: `docs/urdf.md`. 3MF face tags: `docs/formats/3mf-face-tags.md`.
|
||||
|
||||
@@ -23,5 +23,5 @@ The authority is `solidcore/docs/layers.md`; this is its short form. Every job l
|
||||
## Working in solidcore/
|
||||
- Follow `solidcore/AGENTS.md` and the reference doc for the area (`solidcore/docs/`). A change there needs a code owner's review (`.github/CODEOWNERS`) and runs SolidCore checks (its tests, goldens, kernel rig, clippy, fmt, panic sites) in `.github/workflows/ci.yml`.
|
||||
- New kernel cases go in `solidcore/crates/solidcore/tests/kernel_cases/`, never in SolidCraft (`cargo xtask lists`).
|
||||
- Kernel work goes here: CONTRIBUTING.md, "Kernel work goes in solidcore/". Kernel-only PRs target the `kernel` branch.
|
||||
- The kernel pause in CONTRIBUTING.md ("Kernel work is paused") still applies to `solidcore/`.
|
||||
- Issues and claims for solidcore are in this repository, like any other change (`skills/solidcraft-contributing/SKILL.md`): the vendored tree is mirrored to tangency-labs/solidcore by `.github/workflows/solidcore-mirror.yml`, so pull requests are not opened there.
|
||||
|
||||
@@ -15,7 +15,7 @@ description: "Use when a SolidCraft change is slow, blocks the window, runs long
|
||||
- Profile first. Time with `solidcraft-cli` from `main` and from your branch, alternating runs, after the load is low. On a shared machine check `cut -d' ' -f1 /proc/loadavg` and wait while it is over 24; build with `nice`.
|
||||
- Report medians with the range, the machine, and the cases. The pattern to follow is `docs/perf.md`: what was slow, why, the result, and proof that nothing changed (same bodies, volumes, areas and triangle counts).
|
||||
- A speed-up never skips work on a mesh's box: decide with the exact bounds (`Body::exact_bounds`, grown by the record's tolerance as `apart` in `crates/kernel/src/ops.rs` does). A mesh is for display, export, previews and mesh bodies.
|
||||
- A speed-up of kernel code goes in `solidcore/` with its root cause, per `solidcore/AGENTS.md`; do not grow `crates/kernel`. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
|
||||
- Do not rewrite kernel code for speed: the kernel is being replaced, and kernel work is paused. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
|
||||
- Pull requests run only the tests for what they touch; geometry-heavy tests run in the merge queue. Use `cargo xtask test` with the `ci` profile locally (`skills/solidcraft-tests/SKILL.md`).
|
||||
|
||||
## The browser build
|
||||
|
||||
Vendored
+14
@@ -230,6 +230,20 @@ jobs:
|
||||
- run: rustup target add wasm32-unknown-unknown
|
||||
- run: cargo check -p solidcore --target wasm32-unknown-unknown
|
||||
|
||||
# Each package builds on the Rust release its `rust-version` declares (scripts/ci/
|
||||
# check-rust-version.sh). Downstream users build on older pinned toolchains than
|
||||
# rust-toolchain.toml's, and nothing else here runs one.
|
||||
rust-version:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.full == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> bin"
|
||||
- run: scripts/ci/check-rust-version.sh
|
||||
|
||||
# Same packaging check the release workflow runs before publishing (no secrets).
|
||||
publish-dry-run:
|
||||
needs: changes
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
> **solidcore is developed inside [SolidCraft](https://github.com/tangency-labs/solidcraft), at [`solidcore/`](https://github.com/tangency-labs/solidcraft/tree/main/solidcore).** The [tangency-labs/solidcore](https://github.com/tangency-labs/solidcore) repository is an automatic, read-only mirror of that folder. Open issues and pull requests in [tangency-labs/solidcraft](https://github.com/tangency-labs/solidcraft): kernel-only work against the `kernel` branch.
|
||||
|
||||
<p align="center"><img src="assets/logo.svg" width="140" alt="solidcore logo: an isometric solid with one filleted edge and a NURBS curve on its top face"></p>
|
||||
|
||||
# solidcore
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{"format":1,"unit":"blend.feature_executive"}
|
||||
{"case":"add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[326.0,346.0,12744.0]],"n":124}}}
|
||||
{"case":"all_faces_defillet","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1743,230242],[2881,397902],[3189,537461],[5001,812093],[4768,323418]],"n":1165}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[14.000069999999997,14.000069999999997,140.00077]],"n":276},"feature0_record":{"blocks":[[154466.63959673684,154466.63959673684,18841428.541271232],[129333.9587499411,129333.9587499411,14745007.961884614],[90266.21715728752,90266.21715728752,11236079.691673888],[9.0,9.0,68.0]],"n":779},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,9.0,1.0,0.0]}}
|
||||
{"case":"all_faces_filleted_box","deferred":null,"error":null,"ints":{"base_graph":[1,0,0,0,0,0,0,0,0,0,1],"feature0_graph":{"blocks":[[1903,220645],[3189,516856],[3449,269618],[5567,663498],[7339,1115480],[11638,1409058]],"n":1528}},"topology":null,"values":{"base_geometry":[],"base_record":[0.0,0.0],"feature0_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[452.0003399999995,452.0003399999995,26406.02375]],"n":378},"feature0_record":{"blocks":[[155397.5,155397.5,18957409.781469982],[154927.4900204164,154927.4900204164,20151093.307439756],[103966.76733889646,103996.76733889646,12124882.750080192],[75978.24264068715,76068.24264068715,7458816.37737983]],"n":988},"record":[1.0,0.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0,10.0,1.0,0.0]}}
|
||||
{"case":"attributes_add_by_faces_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2774,380658],[2585,341911],[6017,756869],[2124,294143],[7047,692538],[7860,1181034],[13215,1649521],[2767,78418]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176984],[2441,349823],[4137,677755],[748,14692]],"n":804},"feature2_graph":{"blocks":[[1305,197906],[1748,275169],[1932,107176]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115412070844,687.1779118214433,52744.46080298995]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20154954.18077134],[116389.18300344986,116429.18300344986,12866754.328732701],[106102.57307303276,106122.57307303276,13684737.362150839],[45934.73239171605,45934.73239171605,3587621.6467926963]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6389177.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155065.5,155109.5,18928751.0],[105021.5,105109.5,12645808.0],[135.0,135.0,1804.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15852924.877656039],[60998.52675559926,60998.52675559926,4015371.2724186946]],"n":423},"record":{"blocks":[[337.0,357.0,14391.0]],"n":164}}}
|
||||
{"case":"attributes_inner_loop_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2548,339314],[2808,388653],[5366,597446],[3316,572452],[5984,574809],[9450,1382528],[11491,1234945]],"n":1760},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261}},"topology":null,"values":{"base_geometry":{"blocks":[[2.0799999999999883e+22,2.0799999999999883e+22,2.1736000000000002e+24],[363.8030081955735,560.3693788099324,25313.152184450642]],"n":418},"base_record":{"blocks":[[155416.25547015207,155492.8218407664,18950600.3615193],[155118.1152126431,155118.1152126431,20152219.349649835],[116983.14328273362,117023.14328273362,13745846.636862068],[91170.82416150477,91234.82416150477,11435584.550245512],[30337.0,30353.0,1019935.0]],"n":1118},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"record":{"blocks":[[81.0,89.0,1463.0]],"n":58}}}
|
||||
{"case":"attributes_rebuild_fillets_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1906,252991],[3366,516788],[2314,400069],[4443,571496],[8162,998322]],"n":1264},"feature0_graph":{"blocks":[[853,122476],[847,63880]],"n":377}},"topology":null,"values":{"base_geometry":{"blocks":[[1.439999999999995e+22,1.439999999999995e+22,1.044e+24],[21.200139999999998,21.200139999999998,379.20287]],"n":296},"base_record":{"blocks":[[154399.14159265358,154399.14159265358,18832865.4645943],[128406.54159265356,128406.54159265356,14075364.427283542],[105272.0,105272.0,13472688.57781746],[15078.741592653581,15082.741592653596,542971.1221134894]],"n":844},"feature0_geometry":{"blocks":[[4e+21,4e+21,8.199999999999998e+22]],"n":82},"feature0_record":{"blocks":[[115424.47391131187,115428.47391131187,10922544.14210909]],"n":245},"record":{"blocks":[[79.0,89.0,1508.0]],"n":70}}}
|
||||
{"case":"attributes_restore_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1508,183428],[2419,270746],[3057,403507],[6277,856170],[551,3370]],"n":1035},"feature0_graph":{"blocks":[[278,24878]],"n":146}},"topology":null,"values":{"base_geometry":{"blocks":[[1.1999999999999975e+22,1.1999999999999975e+22,7.260000000000001e+23]],"n":252},"base_record":{"blocks":[[154397.14159265358,154397.14159265358,18832863.4645943],[130360.84159265357,130360.84159265357,15657691.00865363],[60190.0,60190.0,6048065.0]],"n":714},"feature0_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":38},"feature0_record":{"blocks":[[57216.03231865831,57216.03231865831,2227522.302144891]],"n":115},"record":{"blocks":[[59.0,67.0,823.0]],"n":55}}}
|
||||
@@ -54,6 +56,9 @@
|
||||
{"case":"fillet_group_torii_fillet_rb11","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
|
||||
{"case":"fillet_group_torii_fillet_rb23","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2573,312590],[2235,302121],[7287,1092477],[3374,249678],[9425,1441704],[5510,902128],[13186,1872602],[16615,2004017],[1666,20910]],"n":2071}},"topology":null,"values":{"base_geometry":{"blocks":[[2.5599999999999832e+22,2.5599999999999832e+22,3.2896e+24],[8e+20,8e+20,3.6e+21],[1.0000200000000001,1.0000200000000001,3.00005]],"n":516},"base_record":{"blocks":[[155035.83440579096,155661.89765365858,18948680.82052572],[155281.77152757163,155574.77152757163,20161266.305777643],[141134.20495401855,141407.41630458267,17778418.33330449],[105017.50209477526,105586.50209477526,11821412.01749248],[91174.28875598182,91629.28875598182,10910463.960739516],[15238.813143693185,15288.813143693185,109863.88984156726]],"n":1327},"record":[0.0,0.0,1.0,0.0,0.0]}}
|
||||
{"case":"mitred_rounds_box_top_r3","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2083,253864],[3024,468943],[3863,321220],[5564,804198],[5754,904563],[11792,1526882],[1838,41095]],"n":1577},"feature0_graph":{"blocks":[[1806,224222],[3712,613264],[2805,507349],[4462,572148],[9492,1266469],[1232,21604]],"n":1313}},"topology":null,"values":{"base_geometry":{"blocks":[[1.91999999999999e+22,1.91999999999999e+22,1.8528000000000002e+24],[782.0829429411759,782.082942941176,29519.122368823526]],"n":383},"base_record":{"blocks":[[156507.5,156507.5,19111185.0],[156364.54117647058,156364.54117647058,20322396.93382353],[105433.0411764706,105433.0411764706,12327102.061764706],[94630.0,95718.0,11327548.0],[210.0,210.0,660.0]],"n":1029},"feature0_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[37.98568732985196,37.98568732985196,788.0151850985815]],"n":313},"feature0_record":{"blocks":[[156703.22494602168,156703.22494602168,19162418.692349844],[143258.58235294116,143258.58235294116,17060525.736758336],[93725.45584412271,94813.45584412271,10865237.012552246],[31834.0,31834.0,749544.5]],"n":851},"record":{"blocks":[[180.0,190.0,3080.0]],"n":52}}}
|
||||
{"case":"multi_feature_adv_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2764,379783],[2576,339959],[6104,754984],[2151,298312],[7054,677922],[7816,1172299],[13400,1684853],[2574,69936]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,198056],[1736,271264],[1944,108295]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[638.8841584798115,775.4505290941705,56271.317757521465]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155094.1600378454,155126.1600378454,20164389.693007357],[116325.18331626526,116365.18331626526,12865628.669316046],[106093.77870914956,106113.77870914956,13675114.593415532],[45943.52615059925,45943.52615059925,3582894.5606996478]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.34995257392,142729.34995257392,15852336.370148472],[60998.52615059925,60998.52615059925,4010652.7699044417]],"n":423},"record":{"blocks":[[326.0,346.0,13094.0]],"n":124}}}
|
||||
{"case":"multi_feature_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751066],[2174,302712],[6982,679564],[7880,1185829],[13383,1678399],[2591,69948]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176978],[2441,349693],[4111,670924],[774,14683]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,687.1779218214433,52984.82530328035]],"n":442},"base_record":{"blocks":[[155445.255470152,155489.82184076635,18956276.02122212],[155029.88762338806,155061.88762338806,20148234.18077134],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106122.57308303277,13677357.359610839],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87791.71681469283,87832.84955592154,6385487.88502191]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105063.5,105159.5,12655567.5],[75.0,75.0,967.0]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[326.0,346.0,13090.0]],"n":124}}}
|
||||
{"case":"multi_rebuild_0_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2771,380151],[2581,340435],[6027,751042],[2174,302712],[6995,682597],[7865,1184614],[13385,1678999],[2591,70004]],"n":1843},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[1305,197879],[1748,275250],[1932,107087]],"n":623}},"topology":null,"values":{"base_geometry":{"blocks":[[2.1599999999999874e+22,2.1599999999999874e+22,2.3436e+24],[550.6115312070845,667.1779218214434,52652.187164461]],"n":442},"base_record":{"blocks":[[155451.53865545918,155483.53865545918,18959902.682866313],[155023.60443808086,155068.17080869523,20150495.966324273],[116389.18300344986,116429.18300344986,12864740.769140378],[106102.57306303276,106103.90832107945,13677333.40532395],[45934.73234671234,45934.73240671234,3583931.6460126126]],"n":1183},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[7.2e+21,7.2e+21,2.628e+23]],"n":148},"feature2_record":{"blocks":[[142729.0772253012,142729.0772253012,15851718.332201496],[60998.526700595554,60998.52678059555,4011681.271218581]],"n":423},"record":{"blocks":[[322.0,342.0,12969.0]],"n":127}}}
|
||||
{"case":"multi_rebuild_1_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2077,266206],[3633,579612],[2138,226476],[4989,506469],[8927,1280475],[3025,147354]],"n":1369},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804},"feature2_graph":{"blocks":[[293,26021]],"n":149}},"topology":null,"values":{"base_geometry":{"blocks":[[1.5999999999999933e+22,1.5999999999999933e+22,1.288e+24],[110.8481596440759,207.41453025843512,3679.688704523623]],"n":343},"base_record":{"blocks":[[155448.14519604263,155480.14519604263,18960494.546111137],[155020.50867205963,155065.07504267397,20143946.545116752],[91052.57229961093,91093.23991863422,11082444.561509266],[45484.33380951166,45485.00142853499,2282412.032856306]],"n":899},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"feature2_geometry":{"blocks":[[1.6e+21,1.6e+21,1.36e+22]],"n":49},"feature2_record":{"blocks":[[71831.53239638661,71831.53239638661,3671408.0937480605]],"n":139},"record":{"blocks":[[287.0,307.0,9792.0]],"n":127}}}
|
||||
{"case":"multi_rebuild_2_extrusion","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[2207,279844],[3288,509422],[3158,278508],[4947,640592],[6593,1014126],[9416,988308]],"n":1500},"feature0_graph":{"blocks":[[683,104052],[34,109]],"n":261},"feature1_graph":{"blocks":[[1287,176993],[2441,349514],[4107,669726],[778,14797]],"n":804}},"topology":null,"values":{"base_geometry":{"blocks":[[1.7599999999999916e+22,1.7599999999999916e+22,1.5576000000000002e+24],[152.8067495591884,269.37312017354753,7235.722609831468]],"n":364},"base_record":{"blocks":[[155260.6164362172,155305.18280683155,18929403.35444251],[155162.03743188508,155194.03743188508,20182286.063773222],[91107.57229961093,91148.23991863422,11083020.561509266],[60725.11195932037,60725.779578343696,4299053.8873962285]],"n":940},"feature0_geometry":{"blocks":[[2.4e+21,2.4e+21,2.9999999999999996e+22]],"n":56},"feature0_record":{"blocks":[[87795.71681469283,87822.18479396813,6386245.248830656]],"n":197},"feature1_geometry":{"blocks":[[9.6e+21,9.6e+21,4.6559999999999995e+23]],"n":194},"feature1_record":{"blocks":[[155083.5,155119.5,18928798.5],[105057.83309524418,105149.83309524418,12654314.892937822],[76.66690475583123,76.66690475583123,1007.5613490585596]],"n":536},"record":{"blocks":[[271.0,287.0,7844.0]],"n":102}}}
|
||||
{"case":"no_ops_defillet","deferred":null,"error":null,"ints":{"base_graph":{"blocks":[[1728,230155],[2865,397674],[3250,542715],[4992,815381],[4729,327785]],"n":1165},"feature0_none":[1]},"topology":null,"values":{"base_geometry":{"blocks":[[1.3599999999999958e+22,1.3599999999999958e+22,9.316000000000001e+23],[11.000069999999997,11.000069999999997,92.00077000000002]],"n":276},"base_record":{"blocks":[[154398.14159265358,154398.14159265358,18832864.4645943],[129390.13959673682,129390.13959673682,14750795.369363222],[90273.51715728751,90273.51715728751,11239145.200591717],[14.017157287525382,14.017157287525382,85.60294372515229]],"n":779},"record":{"blocks":[[48.0,64.0,438.0]],"n":43}}}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
{"case":"gen0039","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1745.6446517672211,1759.0009086963332,53080.473545951456]],"n":61}}}
|
||||
{"case":"gen0040","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1293.1780750967052,1331.5983705234573,43731.711951791374]],"n":54}}}
|
||||
{"case":"gen0041","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-1.512782707950123,1082.7428297938636,-509.6925401146473]],"n":89}}}
|
||||
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59502854734214,267.6372546180361,10403.49931860245]],"n":67}}}
|
||||
{"case":"gen0042","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[247.59500994892622,267.637273216452,10403.498983830963]],"n":67}}}
|
||||
{"case":"gen0043","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[["inf","inf","inf"]],"n":58}}}
|
||||
{"case":"gen0044","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[2363.331389460768,2381.342638010389,23505.946664766696]],"n":54}}}
|
||||
{"case":"gen0045","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[46563.40296431203,46630.561956428515,823550.7565376083]],"n":131}}}
|
||||
@@ -909,7 +909,7 @@
|
||||
{"case":"gen0852","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[160.1181334263939,588.6357976688842,7315.822570183931]],"n":78}}}
|
||||
{"case":"gen0853","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[483.08853328132193,583.7951633580492,21580.922615116793]],"n":68}}}
|
||||
{"case":"gen0854","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1904.368161806397,1986.2846515331662,41598.7870653989]],"n":65}}}
|
||||
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02409151847633,410.5124223109191,-4682.4589894044575]],"n":68}}}
|
||||
{"case":"gen0855","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[-156.02408784125566,410.5124186336983,-4682.458967341133]],"n":68}}}
|
||||
{"case":"gen0856","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1840.2670017776718,2073.924694260032,52771.46827067364]],"n":81}}}
|
||||
{"case":"gen0857","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[344.30209778448113,387.76064706770353,11668.607945433409]],"n":56}}}
|
||||
{"case":"gen0858","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[80.44331673962174,1051.7564644076338,4225.38697740385]],"n":89}}}
|
||||
@@ -1655,7 +1655,7 @@
|
||||
{"case":"gen1598","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1107.122912136224,2062.064170269247,18820.15307682447]],"n":100}}}
|
||||
{"case":"gen1599","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1649.8413176227855,1688.9076570088273,82824.75732259153]],"n":80}}}
|
||||
{"case":"gen1600","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1328.8306468841884,1537.8237072141824,64621.894244369585]],"n":89}}}
|
||||
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.6209888918087,260.6012897777472,8049.2557268355995]],"n":55}}}
|
||||
{"case":"gen1601","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[245.62098559210304,260.60129307745285,8049.255707037365]],"n":55}}}
|
||||
{"case":"gen1602","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1565.0240348651373,1591.1848258051928,97009.86974045317]],"n":97}}}
|
||||
{"case":"gen1603","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[1295.903574378681,1475.9072442360741,96443.64845655844]],"n":103}}}
|
||||
{"case":"gen1604","deferred":null,"error":null,"ints":{},"topology":null,"values":{"rec":{"blocks":[[44185.678798756344,46310.99065267807,787573.7979235228]],"n":172}}}
|
||||
|
||||
+5
-5
@@ -26,7 +26,7 @@
|
||||
{"case":"edge_negate","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[-3.0,-1.0]}}
|
||||
{"case":"edge_periodic_degenerate_wrap","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[3.0,1.0,0.0,0.0,1.0,0.0]}}
|
||||
{"case":"edge_periodic_huge_value","deferred":null,"error":null,"ints":{"flags":[0,0,1,1]},"topology":null,"values":{"values":[1e+20,1e+20,-1e+20,-1e+20,1.0,-1e+20]}}
|
||||
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,0.0,0.0,90.0,0.0,0.0]}}
|
||||
{"case":"edge_periodic_near_top","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[89.99999000000003,-9.999999974752427e-6,9.999999974752427e-6,90.00000999999997,-9.999999974752427e-6,9.999999974752427e-6]}}
|
||||
{"case":"edge_periodic_negative_period","deferred":null,"error":null,"ints":{"flags":[0,0,1,0]},"topology":null,"values":{"values":[20.0,-160.0,-200.0,-610.0,90.0,-610.0]}}
|
||||
{"case":"edge_periodic_zero_period","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1.0,3.0,3.0,2.0,1.0,2.0]}}
|
||||
{"case":"edge_same_scaled_zero","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,1]},"topology":null,"values":{"intersect":[5e-13,1000000.0],"union":[0.0,1000000.0000005]}}
|
||||
@@ -921,7 +921,7 @@
|
||||
{"case":"gen0867","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extents":[-4032.5445657100436,0.0,-4032.5445657100436,-4032.5445657100436,1e+20,-1e+20]}}
|
||||
{"case":"gen0868","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-4.547682406915637,-4.547682406915637,2.000177801164682e-12,3.547682406915637,-4.547682406915637,2.000177801164682e-12]}}
|
||||
{"case":"gen0869","deferred":null,"error":null,"ints":{"disjoint":[0]},"topology":null,"values":{}}
|
||||
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-4.0,0.0,1.000088900582341e-12,-4.0,0.0]}}
|
||||
{"case":"gen0870","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-3.999999999999,-3.999999999999,1.000088900582341e-12,0.0,-3.999999999999,0.0]}}
|
||||
{"case":"gen0871","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-999999.999901,-2.999901,999997.000099,0.00009900000000007125,-2.999901,0.00009900000000007125]}}
|
||||
{"case":"gen0872","deferred":null,"error":null,"ints":{"status_count":[0,0]},"topology":null,"values":{"intervals":[]}}
|
||||
{"case":"gen0873","deferred":null,"error":null,"ints":{"flags":[1,1,1,0]},"topology":null,"values":{"values":[-420.0475459692425,-5864.660909553929,0.0,-5444.613363584686,-5864.660909553929,-5444.613363584686]}}
|
||||
@@ -1554,7 +1554,7 @@
|
||||
{"case":"gen1500","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[1e-6,1e-6,1e+20,1e+20,-1e+20,1e+20]}}
|
||||
{"case":"gen1501","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.9587557303154033,-0.9587557303154033,4.041244269684597,0.3265002390765175,-0.9587557303154033,0.3265002390765175]}}
|
||||
{"case":"gen1502","deferred":null,"error":null,"ints":{"counts":[0],"flag":[0]},"topology":null,"values":{"clamped":[-3.6531332205612728]}}
|
||||
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009409,0.0,3.841259804200941,-0.8412598042009409,0.0]}}
|
||||
{"case":"gen1503","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.8431568102231495,-0.8412598042009449,3.9968028886505635e-15,3.841259804200945,-0.8412598042009449,3.9968028886505635e-15]}}
|
||||
{"case":"gen1504","deferred":null,"error":null,"ints":{"flags":[1,1,0,0,0,1,1,1,1,1,0,0,0]},"topology":null,"values":{"values":[-5.0,8.696559494945177,1.8482797474725885,13.696559494945177,8.696559494945177,0.0,0.0,-5.0,0.0,-5.0,0.3650551805981122]}}
|
||||
{"case":"gen1505","deferred":null,"error":null,"ints":{"flags":[0,0,0,0,0,0,0]},"topology":null,"values":{"intersect":[2.5565017038286015,3.0],"union":[-7.213387952269873e-10,3.165025619210411]}}
|
||||
{"case":"gen1506","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[5605.960734602222,5605.960734602222,0.0,0.0,5605.960734602222,0.0]}}
|
||||
@@ -2114,7 +2114,7 @@
|
||||
{"case":"gen2060","deferred":null,"error":null,"ints":{"flags":[0,1,0,0,0,1,1,1,0,4,0,0,1001]},"topology":null,"values":{"values":[3564.9311334680096,3564.9311344680095,3564.93113396801,9.99999883788405e-7,3564.9311344680095,3564.9311344680095,3564.9311344680095,3564.93469839883,0.00010000000020227162,3564.9311334680096,101.0000118234325]}}
|
||||
{"case":"gen2061","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
|
||||
{"case":"gen2062","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"extent":[2.0,360.0]}}
|
||||
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095653894,0.0,0.0009064156015241429,-0.0009064160095653894,0.0]}}
|
||||
{"case":"gen2063","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[-0.0001752855794858732,-0.0009064160095650919,2.975050761300224e-16,0.0009064156015238454,-0.0009064160095650919,2.975050761300224e-16]}}
|
||||
{"case":"gen2064","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[0.465547398032645,1000000.0]}}
|
||||
{"case":"gen2065","deferred":null,"error":null,"ints":{"flag":[1]},"topology":null,"values":{"clamped":[7.390963598478955,-5.0,-0.8157526895288466,-3.0,2.9999,0.0,-1e+20,-3.3306754088323505,7.774316697598074,4.0,-4.903134706608148,0.0,-1.5550380498551388,-4.0]}}
|
||||
{"case":"gen2066","deferred":null,"error":null,"ints":{"flags":[1,1,0,1]},"topology":null,"values":{"values":[-3482.7204020819313,-3482.7204020819313,9.99999883788405e-7,3478.7204020819313,-3482.7204020819313,9.99999883788405e-7]}}
|
||||
@@ -2318,7 +2318,7 @@
|
||||
{"case":"gen2264","deferred":null,"error":null,"ints":{"status":[1007]},"topology":null,"values":{"extent":[1e+20,-1e+20]}}
|
||||
{"case":"gen2265","deferred":null,"error":null,"ints":{"flags":[1001,1,1,0,0,0,0]},"topology":null,"values":{"intersect":[1e+20,-1e+20],"union":[-1234567.0,1234567.0]}}
|
||||
{"case":"gen2266","deferred":null,"error":null,"ints":{"flags":[0,0,0,0]},"topology":null,"values":{"values":[3.5,-0.5005336876101969,0.5,4.500533687610197,-0.0005336876101968466,0.5]}}
|
||||
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783426,0.0,2.0009838062297836,0.000983806229783426,0.0]}}
|
||||
{"case":"gen2267","deferred":null,"error":null,"ints":{"flags":[1,1,0,0]},"topology":null,"values":{"values":[0.005902837378700454,0.000983806229783113,3.131175874138137e-16,2.000983806229783,0.000983806229783113,3.131175874138137e-16]}}
|
||||
{"case":"gen2268","deferred":null,"error":null,"ints":{},"topology":null,"values":{"approx":[-0.0,0.7741788387164605],"approx_plain":[-0.0,0.7741788387164605],"expanded":[-0.0,0.7741788387164605]}}
|
||||
{"case":"gen2269","deferred":null,"error":null,"ints":{"flags":[1,1,1,1]},"topology":null,"values":{"values":[4.700315805143418,302.8236045754655,0.0,1.9895196601282805e-12,302.8236045754655,0.0]}}
|
||||
{"case":"gen2270","deferred":null,"error":null,"ints":{"counts":[],"flag":[0]},"topology":null,"values":{"clamped":[]}}
|
||||
|
||||
+1
-1
@@ -3029,7 +3029,7 @@
|
||||
{"case":"my_test_extent2d_classify_point_tol_v","deferred":null,"error":null,"ints":{"classify":[1,0],"contains":[1,1],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.1,2.1],"outside_dist":[0.0]}}
|
||||
{"case":"my_test_extent2d_clip_line","deferred":null,"error":null,"ints":{},"topology":null,"values":{"t":[1.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic","deferred":null,"error":null,"ints":{"contained":[0],"contained_again":[0]},"topology":null,"values":{"periodic_point":[4.9,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[1.0,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_periodic_tol","deferred":null,"error":null,"ints":{"contained":[1],"contained_again":[1]},"topology":null,"values":{"periodic_point":[0.9000000000000004,3.0]}}
|
||||
{"case":"my_test_extent2d_contains_point","deferred":null,"error":null,"ints":{"classify":[4,1],"contains":[0,0],"left_eval":[1,1],"on_boundary":[0],"on_boundary_bits":[0,0]},"topology":null,"values":{"binormal":[0.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.09999999999999998]}}
|
||||
{"case":"my_test_extent2d_contains_point_tol","deferred":null,"error":null,"ints":{"classify":[0,1],"contains":[1,1],"left_eval":[1,1],"on_boundary":[1],"on_boundary_bits":[1,1]},"topology":null,"values":{"binormal":[1.0,0.0],"clamp":[1.0,3.0],"outside_dist":[0.0]}}
|
||||
{"case":"my_test_extent2d_e1_from_points","deferred":null,"error":null,"ints":{},"topology":null,"values":{"extent":[1.0,2.0,3.0,4.0]}}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"blend.feature_executive": {
|
||||
"add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"attributes_add_by_faces_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_feature_adv_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_feature_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"multi_rebuild_0_extrusion": "tangency-labs/solidcore#3093: the base body is a closed solid or an open 9-face body depending on a few ulps of the extrusion surface's extended domain, with success either way",
|
||||
"single_face_fillet1019_6": "tangency-labs/solidcore#3093: success with a body that fails check_brep level 2 (11 lines, the first Bad Repeated CPts)"
|
||||
},
|
||||
"blend.fillet_executive_p3": {
|
||||
|
||||
+1
-1
@@ -350,7 +350,7 @@
|
||||
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f0.0_c2_t0.5_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f0.5_c1_t0.5_0.5","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":"rejected","ints":{},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m0_f1e-09_c2_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,1,5]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t0.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m1_f0.5_c1_t1.0_0.0","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
{"case":"adv_sec_b8_e2_m2_f0.5_c2_t0.25_0.75","deferred":null,"error":null,"ints":{"sector":[1,0,0]},"topology":null,"values":{}}
|
||||
|
||||
+6
-6
@@ -11,6 +11,7 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::mass::enclosure::VolumeSign;
|
||||
use solidcore::topology::brep::validation;
|
||||
use solidcore::topology::Brep;
|
||||
|
||||
@@ -25,8 +26,8 @@ pub struct Soundness {
|
||||
pub check: Result<Vec<String>, String>,
|
||||
/// Whether the body is a manifold solid.
|
||||
pub manifold: Option<bool>,
|
||||
/// Its volume, when it is a manifold solid.
|
||||
pub volume: Option<f64>,
|
||||
/// Its volume's sign, when it is a manifold solid.
|
||||
pub volume: Option<VolumeSign>,
|
||||
/// Its face, edge and vertex counts.
|
||||
pub counts: [usize; 3],
|
||||
}
|
||||
@@ -35,7 +36,7 @@ impl Soundness {
|
||||
/// Whether the check is clean and a manifold body has a positive volume.
|
||||
pub fn sound(&self) -> bool {
|
||||
self.check.as_ref().is_ok_and(Vec::is_empty)
|
||||
&& (self.manifold != Some(true) || self.volume.is_some_and(|v| v > 0.0))
|
||||
&& (self.manifold != Some(true) || matches!(self.volume, Some(VolumeSign::Positive(_))))
|
||||
}
|
||||
|
||||
/// One line: `sound`, the check's line count, manifold, volume and counts.
|
||||
@@ -164,7 +165,7 @@ pub fn probe(runner: &dyn crate::UnitDiff) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{measure, start, take};
|
||||
use super::{measure, start, take, VolumeSign};
|
||||
use solidcore::kernel::Kernel;
|
||||
|
||||
fn cube() -> solidcore::topology::Brep {
|
||||
@@ -198,8 +199,7 @@ mod tests {
|
||||
assert!(s.sound(), "{}", s.line());
|
||||
assert_eq!(s.manifold, Some(true));
|
||||
assert!(
|
||||
s.volume
|
||||
.is_some_and(|v| (v - 1000.0).abs() <= 1e-6 * 1000.0),
|
||||
matches!(s.volume, Some(VolumeSign::Positive(e)) if e.lo <= 1000.0 && 1000.0 <= e.hi),
|
||||
"{}",
|
||||
s.line()
|
||||
);
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
rust-version.workspace = true
|
||||
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/tangency-labs/solidcore"
|
||||
keywords = ["cad", "brep", "nurbs", "geometry"]
|
||||
categories = ["data-structures", "science"]
|
||||
|
||||
+151
-7
@@ -21,6 +21,8 @@
|
||||
#![allow(clippy::field_reassign_with_default)]
|
||||
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcore::curve::curve::Curve as KernelCurve;
|
||||
use solidcore::curve::from_parameters::{
|
||||
self as curves, approx_tolerance, zone_tolerance, MAX_TOLERANCE,
|
||||
@@ -93,6 +95,86 @@ pub struct Order {
|
||||
pub vertices: Vec<VertexId>,
|
||||
}
|
||||
|
||||
/// A kernel edge solids touch along, as [`to_record`] writes it: one record edge per pair of
|
||||
/// faces it separates (SolidCraft's `history::Piece`). An edge with uses on three or more faces
|
||||
/// has one [`Piece`] per record edge [`to_record`] copied it into, each with the faces of that
|
||||
/// copy's own uses (two, for the sector pairs the radial order gives); none is merged or split
|
||||
/// here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Piece {
|
||||
/// The kernel edge.
|
||||
pub kernel: EdgeId,
|
||||
/// One of its record edges.
|
||||
pub edge: u32,
|
||||
/// The record faces that record edge bounds, ascending.
|
||||
pub faces: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
/// Each kernel face's record face (the inverse of [`Order::faces`]).
|
||||
#[must_use]
|
||||
pub fn record_faces(&self) -> BTreeMap<FaceId, u32> {
|
||||
inverse(&self.faces)
|
||||
}
|
||||
|
||||
/// Each kernel edge's first record edge (the inverse of [`Order::edges`]; a kernel edge
|
||||
/// solids touch along has more, its [`Order::pieces`]).
|
||||
#[must_use]
|
||||
pub fn record_edges(&self) -> BTreeMap<EdgeId, u32> {
|
||||
inverse(&self.edges)
|
||||
}
|
||||
|
||||
/// Each kernel vertex's first record vertex (the inverse of [`Order::vertices`]).
|
||||
#[must_use]
|
||||
pub fn record_vertices(&self) -> BTreeMap<VertexId, u32> {
|
||||
inverse(&self.vertices)
|
||||
}
|
||||
|
||||
/// Every record edge of each kernel edge that has more than one, in record order, with the
|
||||
/// record faces it bounds (`record` is the record this order came with): the faces of that
|
||||
/// record edge's uses, sorted and without repeats.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Unsupported::Invalid`] for a record whose edges are not this order's.
|
||||
pub fn pieces(&self, record: &r::Brep) -> R<Vec<Piece>> {
|
||||
if record.edges.len() != self.edges.len() {
|
||||
return Err(Unsupported::Invalid("a record not of this order"));
|
||||
}
|
||||
let mut count: BTreeMap<EdgeId, usize> = BTreeMap::new();
|
||||
for &e in &self.edges {
|
||||
*count.entry(e).or_default() += 1;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, (&kernel, e)) in self.edges.iter().zip(&record.edges).enumerate() {
|
||||
if count.get(&kernel).copied().unwrap_or(0) < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut faces: Vec<u32> = e.uses.iter().map(|u| u.face).collect();
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
out.push(Piece {
|
||||
kernel,
|
||||
edge: u32::try_from(i).map_err(|_| Unsupported::Invalid("index"))?,
|
||||
faces,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each entity's first index in `list`. An index past `u32` is left out: unreachable, since
|
||||
/// every list is an [`Order`]'s, indexed by a record's `u32` indices.
|
||||
fn inverse<T: Copy + Ord>(list: &[T]) -> BTreeMap<T, u32> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (i, &x) in list.iter().enumerate() {
|
||||
if let Ok(i) = u32::try_from(i) {
|
||||
out.entry(x).or_insert(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn int(n: usize) -> R<u64> {
|
||||
u64::try_from(n).map_err(|_| Unsupported::Invalid("count"))
|
||||
}
|
||||
@@ -231,8 +313,8 @@ fn build(
|
||||
}
|
||||
|
||||
/// The record as the kernel's B-rep data: edgeuses face by face, loop by loop, in loop order;
|
||||
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance (at
|
||||
/// least the body's); the body and every face take the body's. The record has no face
|
||||
/// edges and vertices in the record's order. Each edge and vertex keeps its own tolerance; the
|
||||
/// body's and each face's are the least of theirs ([`own_tolerances`]). The record has no face
|
||||
/// tolerance: a face's own crosses as its edges' (`to_record` puts it into them).
|
||||
fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
let zone = zone_tolerance(rec.tolerance);
|
||||
@@ -318,7 +400,7 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
// Edges first (their kernel intervals are the trims' parameter), in the record's order.
|
||||
let mut intervals = Vec::new();
|
||||
for e in &rec.edges {
|
||||
let tolerance = e.tolerance.max(rec.tolerance);
|
||||
let tolerance = e.tolerance;
|
||||
if !(tolerance <= MAX_TOLERANCE) {
|
||||
return Err(Unsupported::NotYet(
|
||||
"an edge tolerance over the kernel's limit",
|
||||
@@ -475,9 +557,18 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
let interval = intervals
|
||||
.get(u.edge as usize)
|
||||
.ok_or(Unsupported::Invalid("edge"))?;
|
||||
let source_interval = rec
|
||||
.edges
|
||||
.get(u.edge as usize)
|
||||
.ok_or(Unsupported::Invalid("edge"))?
|
||||
.interval;
|
||||
ud.uv_curve = int(d.uv_curves.len())?;
|
||||
let trim =
|
||||
curves::uv_trim(&crate::curve2(&u.pcurve), interval, made.scale)?;
|
||||
let trim = curves::uv_trim_on_edge(
|
||||
&crate::curve2(&u.pcurve),
|
||||
source_interval,
|
||||
interval,
|
||||
made.scale,
|
||||
)?;
|
||||
d.uv_curves.push(Keyed {
|
||||
key: key(),
|
||||
object: trim,
|
||||
@@ -500,9 +591,9 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
}
|
||||
}
|
||||
|
||||
// Vertices, each with its own tolerance, never below the body's.
|
||||
// Vertices, each with its own tolerance.
|
||||
for v in &rec.vertices {
|
||||
let tolerance = v.tolerance.max(rec.tolerance);
|
||||
let tolerance = v.tolerance;
|
||||
if !(tolerance <= MAX_TOLERANCE) {
|
||||
return Err(Unsupported::NotYet(
|
||||
"a vertex tolerance over the kernel's limit",
|
||||
@@ -514,9 +605,62 @@ fn data(k: &Kernel, rec: &r::Brep, bare: &[(u32, u32, u32)]) -> R<BrepData> {
|
||||
vd.point = Vec3::new(v.point[0], v.point[1], v.point[2]);
|
||||
d.vertices.push(vd);
|
||||
}
|
||||
own_tolerances(&mut d, rec, zone)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The body's and each face's tolerance from the edges' and vertices' own, as SolidCraft's
|
||||
/// flat import took them (`own_tolerances`, after the capi's): the kernel's build raises every
|
||||
/// entity to the body's, so the body's is the least of the record's and its edges' and
|
||||
/// vertices', and a face's the least of its loops' edges' and vertices', at most the record's.
|
||||
/// Each entity keeps its own across the crossing, none raised to the body's.
|
||||
///
|
||||
/// This keeps invariant 5 of `docs/validity-and-tolerances.md` §10 (every local tolerance at
|
||||
/// least the body's) by the other route from raising each entity to the body's (8252ae3e93,
|
||||
/// "never below the body's"): the body comes down to the least of them instead, so no entity
|
||||
/// is below it and none is changed. A face's is at least the body's (both are minima, the
|
||||
/// body's over more) and at most its loops' edges' and vertices'.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Unsupported::Invalid`] when the least zone is not positive and finite: a tolerance so
|
||||
/// small that its zone (half of it) underflows to zero passes the record's validation.
|
||||
fn own_tolerances(d: &mut BrepData, rec: &r::Brep, zone: f64) -> R<()> {
|
||||
let edge_zone = |e: u32| -> R<f64> { Ok(at(&d.edges, e)?.zone_tol_3d) };
|
||||
let vertex_zone = |v: u32| -> R<f64> { Ok(at(&d.vertices, v)?.zone_tol_3d) };
|
||||
let mut faces = Vec::with_capacity(rec.faces.len());
|
||||
for f in &rec.faces {
|
||||
let mut t = f64::INFINITY;
|
||||
for l in &f.loops {
|
||||
match l {
|
||||
r::Loop::Edges { edges } => {
|
||||
for oe in edges {
|
||||
t = t.min(edge_zone(oe.edge)?);
|
||||
}
|
||||
}
|
||||
r::Loop::Vertex { vertex } => t = t.min(vertex_zone(*vertex)?),
|
||||
}
|
||||
}
|
||||
faces.push(if t.is_finite() { t.min(zone) } else { zone });
|
||||
}
|
||||
let least = d
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| e.zone_tol_3d)
|
||||
.chain(d.vertices.iter().map(|v| v.zone_tol_3d))
|
||||
.fold(zone, f64::min);
|
||||
if !(least.is_finite() && least > 0.0) {
|
||||
return Err(Unsupported::Invalid(
|
||||
"a tolerance whose zone (half of it) is zero or not finite",
|
||||
));
|
||||
}
|
||||
d.zone_tol_3d = least;
|
||||
for (fd, t) in d.faces.iter_mut().zip(faces) {
|
||||
fd.zone_tol_3d = t;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The curves of a ball patch face's loops, for [`face_surface`] to certify on its sphere:
|
||||
/// each edge's NURBS form, or its curve; empty for any other face, whose label is not
|
||||
/// checked against its loops.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
//! B-rep data, with no arrays between. What is done here is index mapping and field copies:
|
||||
//!
|
||||
//! - **Geometry:** each edge's curve and each face's surface as the kernel writes its record
|
||||
//! ([`record_of`], [`record_face`]): a line by its two end points, a conic by its own frame and
|
||||
//! ([`record_of`], [`record_face`]): a line as the kernel holds it, a conic by its own frame and
|
||||
//! its NURBS form, proved on each other; an analytic face by its label and its NURBS form,
|
||||
//! proved (`lies_on`); a plane by its placement, its parameters its kernel ones times its
|
||||
//! scales. Nothing is sampled. A curve or surface with no NURBS form (an offset) is refused.
|
||||
|
||||
+76
-4
@@ -226,6 +226,29 @@ fn a_cube_record_builds_the_kernels_cube() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcurve_support_may_extend_past_its_edge_interval() {
|
||||
let mut record = cube(1.0);
|
||||
let Curve::Line { start, end } = &record.edges[0].curve else {
|
||||
panic!("cube line")
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
record.edges[0].curve = Curve::Spline {
|
||||
knots: vec![0.0, 0.0, 1.0, 1.0],
|
||||
points: vec![start, end],
|
||||
};
|
||||
record.edges[0].interval = [0.0, f64::from_bits(1.0_f64.to_bits() - 2)];
|
||||
record.validate().unwrap();
|
||||
let original = record.faces.clone();
|
||||
let mut k = kernel();
|
||||
let (mut body, _) = to_kernel(&mut k, &record, true).unwrap();
|
||||
let (saved, _) = to_record(&mut k, &mut body).unwrap();
|
||||
assert_eq!(
|
||||
saved.faces.iter().map(|f| &f.loops).collect::<Vec<_>>(),
|
||||
original.iter().map(|f| &f.loops).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_the_conversion_cannot_carry_are_refused() {
|
||||
let mut k = kernel();
|
||||
@@ -556,8 +579,8 @@ fn lumps_and_solids_touching_along_an_edge_go_round() {
|
||||
}
|
||||
|
||||
/// An analytic body's record: its curved faces are labels on their NURBS forms, its circles
|
||||
/// labels on theirs, its lines their two end points over [0, 1]; each pcurve on its edge's
|
||||
/// interval.
|
||||
/// labels on theirs, its lines as the kernel holds them (its point and unit vector, over the
|
||||
/// height); each pcurve on its edge's interval.
|
||||
#[test]
|
||||
fn analytic_records_carry_labels_on_their_forms() {
|
||||
let mut k = kernel();
|
||||
@@ -576,8 +599,9 @@ fn analytic_records_carry_labels_on_their_forms() {
|
||||
for e in &r.edges {
|
||||
match &e.curve {
|
||||
Curve::Line { start, end } => {
|
||||
assert_eq!(e.interval, [0.0, 1.0]);
|
||||
assert_eq!((start[2], end[2]), (0.0, 5.0));
|
||||
// as the kernel holds the seam: its point and unit vector, over its height
|
||||
assert_eq!(e.interval, [0.0, 5.0]);
|
||||
assert_eq!((start[2], end[2]), (0.0, 1.0));
|
||||
assert!(e.nurbs.is_none());
|
||||
}
|
||||
Curve::Circle { radius, .. } => {
|
||||
@@ -1146,3 +1170,51 @@ fn a_ball_patch_far_from_the_origin_reads_as_one() {
|
||||
assert!(read_ball(&mut k, &off, i).is_none(), "face {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A record whose body tolerance is above its edges' and vertices' builds with the body's
|
||||
/// lowered to the least of them (the flat import's `own_tolerances`): valid at Level 2, and
|
||||
/// every edge and vertex at least the body's (invariant 5 of `docs/validity-and-tolerances.md`
|
||||
/// §10), each at its own (the zone, half the record's), none raised.
|
||||
#[test]
|
||||
fn a_body_tolerance_above_its_entities_comes_down_to_theirs() {
|
||||
let mut k = kernel();
|
||||
let mut r = cube(10.0);
|
||||
r.tolerance = 1e-4;
|
||||
r.edges[3].tolerance = 4e-6;
|
||||
r.vertices[5].tolerance = 2e-6;
|
||||
r.validate().unwrap();
|
||||
let (mut b, _) = to_kernel(&mut k, &r, true).unwrap();
|
||||
valid(&mut k, &mut b);
|
||||
// the least is the other edges' 1e-6, as a zone: half of it
|
||||
assert_eq!(b.tolerance(), 5e-7);
|
||||
for &e in b.edge_list() {
|
||||
assert!(b.edge(e).unwrap().tolerance >= b.tolerance(), "edge {e:?}");
|
||||
}
|
||||
for &v in b.vertex_list() {
|
||||
assert!(
|
||||
b.vertex(v).unwrap().tolerance >= b.tolerance(),
|
||||
"vertex {v:?}"
|
||||
);
|
||||
}
|
||||
let mut own: Vec<f64> = b
|
||||
.edge_list()
|
||||
.iter()
|
||||
.map(|&e| b.edge(e).unwrap().tolerance)
|
||||
.collect();
|
||||
own.sort_by(f64::total_cmp);
|
||||
own.dedup();
|
||||
assert_eq!(own, vec![5e-7, 2e-6], "edges keep their own zones");
|
||||
}
|
||||
|
||||
/// A tolerance so small that its zone (half of it) underflows to zero passes the record's
|
||||
/// validation and is refused by the conversion, not dropped to the body's.
|
||||
#[test]
|
||||
fn a_tolerance_whose_zone_underflows_is_refused() {
|
||||
let mut k = kernel();
|
||||
let mut r = cube(1.0);
|
||||
r.vertices[0].tolerance = f64::from_bits(1);
|
||||
assert!(matches!(
|
||||
to_kernel(&mut k, &r, true),
|
||||
Err(Unsupported::Invalid(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Parity with SolidCraft's flat-array hop (#3137): every operand record of the rig corpus
|
||||
//! (`crates/solidcore/tests/kernel_cases/`) is what SolidCraft's flat export wrote (record →
|
||||
//! arrays → kernel → arrays → record), or, for a body SolidCraft made or read itself (not
|
||||
//! `trusted`), what it handed the kernel. Each goes to the kernel ([`to_kernel`]) and back
|
||||
//! ([`to_record`]); the record that comes back is compared with the one that went in, field by
|
||||
//! field, bit for bit. A difference is classed by where it is (indices dropped) and, for a
|
||||
//! kind, by what it became; a number within [`ROUNDING`] of its size (relative, no floor) is
|
||||
//! counted as rounding, with its count and the largest.
|
||||
//!
|
||||
//! On the whole corpus (1,333 bodies, at #3199 with kernel case 11991 (#3231) in): 1,259 come
|
||||
//! back bit for bit, 36 within rounding (the largest 4.9e-16 relative), and 38 differ, each in exactly the classes
|
||||
//! [`pinned`] lists for it.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::print_stderr
|
||||
)] // test
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
|
||||
use solidcore_convert::brep::{to_kernel, to_kernel_body, to_record, Unsupported};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn corpus() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
|
||||
}
|
||||
|
||||
fn entries(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
if p.is_dir() {
|
||||
entries(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "json")
|
||||
&& !p.to_string_lossy().ends_with(".bodies.json")
|
||||
&& p.parent() != Some(corpus().as_path())
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Class {
|
||||
bodies: usize,
|
||||
places: usize,
|
||||
examples: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
bodies: usize,
|
||||
identical: usize,
|
||||
rounding_only: usize,
|
||||
rounding_places: usize,
|
||||
rounding_largest: f64,
|
||||
refused_in: Vec<String>,
|
||||
/// Sheets with a face used on its reverse side (every difference follows from that).
|
||||
sheet_side: Vec<String>,
|
||||
refused_out: Vec<String>,
|
||||
classes: BTreeMap<String, Class>,
|
||||
/// Each differing body's classes (sheet-side bodies' too).
|
||||
per_body: BTreeMap<String, BTreeSet<String>>,
|
||||
/// Bodies whose `.tolerance` moved other than down to the least of the record's.
|
||||
tolerance_wrong: Vec<String>,
|
||||
/// Sheet-side bodies whose geometry differs as multisets (not only renumbered).
|
||||
sheet_geometry: Vec<String>,
|
||||
/// (body, face, the record's kind, the kind that came back, trusted)
|
||||
relabelled: Vec<(String, usize, String, String, bool)>,
|
||||
}
|
||||
|
||||
/// The relative size of a difference counted as rounding, not a class: a few ulps.
|
||||
const ROUNDING: f64 = 1e-15;
|
||||
|
||||
fn normal(path: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_index = false;
|
||||
for c in path.chars() {
|
||||
match c {
|
||||
'[' => {
|
||||
in_index = true;
|
||||
out.push_str("[*]");
|
||||
}
|
||||
']' => in_index = false,
|
||||
_ if in_index => {}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every place `a` and `b` differ: (class, place); numbers within [`ROUNDING`] of their size
|
||||
/// (relative, no floor) counted apart, with the largest relative difference.
|
||||
fn walk(
|
||||
at: &str,
|
||||
a: &Value,
|
||||
b: &Value,
|
||||
diffs: &mut Vec<(String, String)>,
|
||||
rounding: &mut (usize, f64),
|
||||
) {
|
||||
if a == b {
|
||||
return;
|
||||
}
|
||||
if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) {
|
||||
let rel = (x - y).abs() / x.abs().max(y.abs());
|
||||
if rel <= ROUNDING {
|
||||
rounding.0 += 1;
|
||||
rounding.1 = rounding.1.max(rel);
|
||||
return;
|
||||
}
|
||||
diffs.push((normal(at), format!("{at}: {x} -> {y}")));
|
||||
return;
|
||||
}
|
||||
match (a, b) {
|
||||
(Value::Object(x), Value::Object(y)) => {
|
||||
for (k, v) in x {
|
||||
walk(
|
||||
&format!("{at}.{k}"),
|
||||
v,
|
||||
y.get(k).unwrap_or(&Value::Null),
|
||||
diffs,
|
||||
rounding,
|
||||
);
|
||||
}
|
||||
for (k, w) in y {
|
||||
if !x.contains_key(k) {
|
||||
walk(&format!("{at}.{k}"), &Value::Null, w, diffs, rounding);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
|
||||
for (i, (v, w)) in x.iter().zip(y).enumerate() {
|
||||
walk(&format!("{at}[{i}]"), v, w, diffs, rounding);
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) => diffs.push((
|
||||
format!("{}: length", normal(at)),
|
||||
format!("{at}: {} -> {}", x.len(), y.len()),
|
||||
)),
|
||||
(Value::String(x), Value::String(y)) => {
|
||||
diffs.push((format!("{}: {x} -> {y}", normal(at)), at.to_owned()));
|
||||
}
|
||||
_ => {
|
||||
let short = |v: &Value| {
|
||||
let s = v.to_string();
|
||||
s.chars().take(60).collect::<String>()
|
||||
};
|
||||
diffs.push((normal(at), format!("{at}: {} -> {}", short(a), short(b))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A record's geometry as multisets, its numbering dropped: the vertex points, each edge's
|
||||
/// curve (a line's two ends unordered), each face's surface and NURBS form.
|
||||
fn geometry(r: &Value) -> [Vec<String>; 3] {
|
||||
let list = |key: &str, f: &dyn Fn(&Value) -> String| -> Vec<String> {
|
||||
let mut v: Vec<String> = r[key]
|
||||
.as_array()
|
||||
.map(|a| a.iter().map(f).collect())
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let curve = |e: &Value| {
|
||||
let c = &e["curve"];
|
||||
if c["kind"] == "line" {
|
||||
let mut ends = [c["start"].to_string(), c["end"].to_string()];
|
||||
ends.sort();
|
||||
format!("line {ends:?}")
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
};
|
||||
[
|
||||
list("vertices", &|v| v["point"].to_string()),
|
||||
list("edges", &curve),
|
||||
list("faces", &|f| format!("{} {}", f["surface"], f["nurbs"])),
|
||||
]
|
||||
}
|
||||
|
||||
fn kind(face: &Value) -> String {
|
||||
face["surface"]["kind"].as_str().unwrap_or("?").to_owned()
|
||||
}
|
||||
|
||||
fn one(name: &str, record: &Record, trusted: bool, fixes: &UpstreamFixes, t: &mut Tally) {
|
||||
t.bodies += 1;
|
||||
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
|
||||
// the record comes back with the `same` one, as SolidCraft's flat export wrote it
|
||||
// (a sheet's shell is listed by one region; a closed shell by the two it separates)
|
||||
let listed = |i: usize| {
|
||||
record
|
||||
.regions
|
||||
.iter()
|
||||
.filter(|r| r.shells.iter().any(|&s| s as usize == i))
|
||||
.count()
|
||||
};
|
||||
let sheet_side = record.shells.iter().enumerate().any(|(i, s)| match s {
|
||||
solidcore_record::brep::Shell::Faces { faces, .. } => {
|
||||
listed(i) == 1 && faces.iter().any(|f| !f.forward)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = *fixes;
|
||||
let built = match to_kernel(&mut k, record, trusted) {
|
||||
Ok((b, _)) => Ok(b),
|
||||
Err(Unsupported::NotYet(w)) if w.contains("healer") => {
|
||||
to_kernel_body(&mut k, record, trusted)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let mut b = match built {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
t.refused_in.push(format!("{name}: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let back = match to_record(&mut k, &mut b) {
|
||||
Ok((r, _)) => r,
|
||||
Err(e) => {
|
||||
t.refused_out.push(format!("{name}: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (a, z) = (
|
||||
serde_json::to_value(record).unwrap(),
|
||||
serde_json::to_value(&back).unwrap(),
|
||||
);
|
||||
// `PARITY_DUMP=<dir>` and `PARITY_ONLY=<part of a name>`: both records written there
|
||||
if let (Ok(dir), Ok(only)) = (std::env::var("PARITY_DUMP"), std::env::var("PARITY_ONLY")) {
|
||||
if name.contains(&only) {
|
||||
let stem = name.replace(['/', ':'], "_");
|
||||
std::fs::write(format!("{dir}/{stem}.in.json"), a.to_string()).unwrap();
|
||||
std::fs::write(format!("{dir}/{stem}.out.json"), z.to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
let mut diffs = Vec::new();
|
||||
let mut rounding = (0, 0.0_f64);
|
||||
walk("", &a, &z, &mut diffs, &mut rounding);
|
||||
t.rounding_places += rounding.0;
|
||||
t.rounding_largest = t.rounding_largest.max(rounding.1);
|
||||
if let (Some(fa), Some(fz)) = (a["faces"].as_array(), z["faces"].as_array()) {
|
||||
for (i, (x, y)) in fa.iter().zip(fz).enumerate() {
|
||||
if kind(x) != kind(y) {
|
||||
t.relabelled
|
||||
.push((name.to_owned(), i, kind(x), kind(y), trusted));
|
||||
}
|
||||
}
|
||||
}
|
||||
if diffs.is_empty() {
|
||||
if rounding.0 == 0 {
|
||||
t.identical += 1;
|
||||
} else {
|
||||
t.rounding_only += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let classes: BTreeSet<String> = diffs.iter().map(|(c, _)| c.clone()).collect();
|
||||
if classes.contains(".tolerance") {
|
||||
// lowered, to the least of the record's own (the flat import's `own_tolerances`)
|
||||
let least = record
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| e.tolerance)
|
||||
.chain(record.vertices.iter().map(|v| v.tolerance))
|
||||
.fold(record.tolerance, f64::min);
|
||||
if !(back.tolerance < record.tolerance && back.tolerance == least) {
|
||||
t.tolerance_wrong.push(format!(
|
||||
"{name}: {} -> {} (least {least})",
|
||||
record.tolerance, back.tolerance
|
||||
));
|
||||
}
|
||||
}
|
||||
t.per_body.insert(name.to_owned(), classes);
|
||||
if sheet_side && geometry(&a) != geometry(&z) {
|
||||
t.sheet_geometry.push(name.to_owned());
|
||||
}
|
||||
if sheet_side {
|
||||
t.sheet_side.push(name.to_owned());
|
||||
return;
|
||||
}
|
||||
let mut seen = BTreeMap::new();
|
||||
for (class, place) in diffs {
|
||||
seen.entry(class).or_insert((0, place)).0 += 1;
|
||||
}
|
||||
for (class, (n, place)) in seen {
|
||||
let tag = if trusted { "" } else { "(untrusted) " };
|
||||
let c = t.classes.entry(format!("{tag}{class}")).or_default();
|
||||
c.bodies += 1;
|
||||
c.places += n;
|
||||
if c.examples.len() < 3 {
|
||||
c.examples.push(format!("{name} {place}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixes() -> UpstreamFixes {
|
||||
let fixes_json: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
let mut fixes = UpstreamFixes::default();
|
||||
for flag in fixes_json.as_array().unwrap() {
|
||||
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
|
||||
}
|
||||
fixes
|
||||
}
|
||||
|
||||
const LABEL: [&str; 6] = [
|
||||
".faces[*].nurbs",
|
||||
".faces[*].surface.frame",
|
||||
".faces[*].surface.homogeneous",
|
||||
".faces[*].surface.u_knots",
|
||||
".faces[*].surface.v_knots",
|
||||
"",
|
||||
];
|
||||
|
||||
/// Every body that does not come back bit for bit (or within [`ROUNDING`]), with exactly the
|
||||
/// classes it differs in: a body that newly differs, one that no longer does, and one whose
|
||||
/// classes change all fail (#3187 review). Each group says why its difference is intended.
|
||||
fn pinned() -> BTreeMap<String, BTreeSet<String>> {
|
||||
let set = |classes: &[&str]| -> BTreeSet<String> {
|
||||
classes
|
||||
.iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(|c| (*c).to_owned())
|
||||
.collect()
|
||||
};
|
||||
let label = |kind: &str, radii: &[&str]| {
|
||||
let mut c = set(&LABEL);
|
||||
c.insert(format!(".faces[*].surface.kind: {kind}"));
|
||||
c.extend(radii.iter().map(|r| format!(".faces[*].surface.{r}")));
|
||||
c
|
||||
};
|
||||
let groups: Vec<(BTreeSet<String>, &[&str])> = vec![
|
||||
// a record whose body tolerance is above its least entity's: the import lowers it to
|
||||
// that least, as the flat import's `own_tolerances` does (checked: only lowered, and
|
||||
// only to the least of the record's own)
|
||||
(
|
||||
set(&[".tolerance"]),
|
||||
&[
|
||||
"continuity/cov-patch-g1-caps-an-opened-tube-end.json:b3",
|
||||
"continuity/cov-patch-g2-caps-an-opened-tube-end.json:b3",
|
||||
"continuity/g1-patch-g2-at-a-corner-whose-rounds-clash.json:b13",
|
||||
"continuity/g1-patch-g3-at-a-corner-whose-rounds-clash.json:b13",
|
||||
"continuity/g2-patch-g2-on-a-hole-in-one-sheet.json:b11",
|
||||
"continuity/g3-patch-g3-on-a-hole-in-one-sheet.json:b11",
|
||||
"fillets/9-fillet-corner-r5-curvature-default-setback.json:b8",
|
||||
"fillets/cov-fillet-g2-pocket-corner.json:b18",
|
||||
"fillets/every-box-corner-three-edges-r3-curvature-setback-3-5.json:b8",
|
||||
"fillets/report-fillet-curvature-three-edges-at-bar-corner-solver-residues.json:b18",
|
||||
"fillets/report-fillet-curvature-three-edges-at-bar-corner.json:b18",
|
||||
"fillets/report-fillet-g2-corner-of-bar-running-into-cylinder.json:b18",
|
||||
"ladder/07-fillets/015-corner-g2-patch.json:b8",
|
||||
"ladder/07-fillets/019-concave-corner-g2-patch.json:b18",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g1.json:b1",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g2.json:b1",
|
||||
"surfaces/cov-blend-step-between-offset-sheets-g3.json:b1",
|
||||
"surfaces/cov-patch-g0-fills-a-240-deg-window-in-a-cylinder.json:b13",
|
||||
"surfaces/cov-patch-g0-fills-a-saddle-window-in-a-torus.json:b13",
|
||||
"surfaces/cov-patch-g1-fills-a-240-deg-window-in-a-cylinder.json:b13",
|
||||
"surfaces/cov-patch-g1-fills-a-saddle-window-in-a-torus.json:b13",
|
||||
"surfaces/cov-patch-open-box-rim-and-stitch-closes-it.json:b3",
|
||||
],
|
||||
),
|
||||
// a plane through three points: the kernel's plane holds no record of which kind it
|
||||
// came from, and the flat export never writes this kind
|
||||
(
|
||||
set(&[
|
||||
".faces[*].surface.kind: plane_through -> plane",
|
||||
".faces[*].surface.u_axis",
|
||||
".faces[*].surface.u_point",
|
||||
".faces[*].surface.v_axis",
|
||||
".faces[*].surface.v_point",
|
||||
]),
|
||||
&[
|
||||
"surfaces/cov-ruled-box-edge-along-the-top-normal.json:b2",
|
||||
"surfaces/cov-ruled-closed-top-chain-tangent-mitred-frame.json:b2",
|
||||
"surfaces/cov-ruled-cylinder-rim-tangent-to-the-top-is-a-flat-ring.json:b2",
|
||||
],
|
||||
),
|
||||
// a label its form is not proved on goes as the form alone (#2540): cylinders whose
|
||||
// frames are off their forms past the rounding `evaluates_as` derives (margins 1.23 to
|
||||
// 2.03, tests/issue_3185_label_margins.rs)
|
||||
(
|
||||
label("cylinder -> rational_spline", &["radius"]),
|
||||
&[
|
||||
"booleans/3456-deep-cut-through-drilled-taper-leaves-base.json:b94",
|
||||
"crashes/2243-step-export-flanged-enclosure.json:b43",
|
||||
"fillets/2903-original-rim-chamfer.json:b0",
|
||||
"fillets/2903-original-rim-round.json:b0",
|
||||
],
|
||||
),
|
||||
// a torus label whose frame is off its form's own first sweep row past the rounding
|
||||
// `evaluates_as` derives (margin 1.69, tests/issue_3185_label_margins.rs)
|
||||
(
|
||||
label(
|
||||
"torus -> rational_spline",
|
||||
&["major_radius", "minor_radius"],
|
||||
),
|
||||
&["sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub.json:b46"],
|
||||
),
|
||||
// an extruded circle the flat hop left a spline, proved a cylinder
|
||||
(
|
||||
label("rational_spline -> cylinder", &["radius"]),
|
||||
&[
|
||||
"tessellation/1615-crossed-lofted-tubes.json:b3",
|
||||
"tessellation/1615-crossed-lofted-tubes.json:b6",
|
||||
],
|
||||
),
|
||||
// a line end the export wrote as 3.1e-16 (a cos 90 deg residue) where the kernel's
|
||||
// line, point plus its vector, holds 0: an absolute 3e-16 on a coordinate of size 0
|
||||
(
|
||||
set(&[".edges[*].curve.end[*]"]),
|
||||
&["assemblies/cov-contact-check-touching-faces-do-not-overlap.json:b725"],
|
||||
),
|
||||
];
|
||||
let mut out = BTreeMap::new();
|
||||
for (classes, bodies) in groups {
|
||||
for b in bodies {
|
||||
out.insert((*b).to_owned(), classes.clone());
|
||||
}
|
||||
}
|
||||
// a sheet face used on its reverse side: the kernel keeps both uses of a sheet's face and
|
||||
// the record comes back with the `same` one, as the flat export's rule says; every
|
||||
// difference is the renumbering that follows (checked: the same vertex points, edge curves
|
||||
// and face surfaces, as multisets), and two of them also lower `.tolerance`
|
||||
let renumbered = [
|
||||
".edges[*].uses[*].position",
|
||||
".faces[*].loops[*].edges[*].edge",
|
||||
".shells[*].faces[*].forward",
|
||||
".vertices[*].point[*]",
|
||||
];
|
||||
let match_sheet = [
|
||||
".edges[*].curve.knots: length",
|
||||
".edges[*].curve.points: length",
|
||||
".edges[*].uses[*].upper",
|
||||
];
|
||||
let reverse = [
|
||||
".edges[*].curve.end[*]",
|
||||
".edges[*].curve.start[*]",
|
||||
".edges[*].vertices[*]",
|
||||
];
|
||||
let with = |extra: &[&[&str]]| {
|
||||
let mut c = set(&renumbered);
|
||||
for e in extra {
|
||||
c.extend(set(e));
|
||||
}
|
||||
c
|
||||
};
|
||||
for (b, classes) in [
|
||||
(
|
||||
"surfaces/cov-match-coplanar-sheet-onto-the-near-edge.json:b1",
|
||||
with(&[&match_sheet]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g1.json:b1",
|
||||
with(&[&match_sheet, &[".tolerance"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-match-raised-sheet-down-onto-the-edge-g3.json:b1",
|
||||
with(&[&match_sheet, &[".tolerance"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-reverse-normal-l-sheet-swaps-the-thicken-side.json:b2",
|
||||
with(&[&reverse, &[".edges[*].uses: length"]]),
|
||||
),
|
||||
(
|
||||
"surfaces/cov-reverse-normal-then-thicken-goes-down.json:b1",
|
||||
with(&[&reverse]),
|
||||
),
|
||||
] {
|
||||
out.insert(b.to_owned(), classes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `stride`th entry; the report is written to `$PARITY_OUT` when set. No body is refused
|
||||
/// either way, and every difference is a named one.
|
||||
fn parity(stride: usize) {
|
||||
let mut files = Vec::new();
|
||||
entries(&corpus(), &mut files);
|
||||
let fixes = fixes();
|
||||
let mut t = Tally::default();
|
||||
let with_bodies = files.iter().filter_map(|f| {
|
||||
let e: Value = serde_json::from_str(&std::fs::read_to_string(f).unwrap()).unwrap();
|
||||
let bodies = e["bodies"].as_object()?.clone();
|
||||
(!bodies.is_empty()).then_some((f.clone(), bodies))
|
||||
});
|
||||
for (f, bodies) in with_bodies.step_by(stride) {
|
||||
let gz = f.with_extension("bodies.json.gz");
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
let short = f.strip_prefix(corpus()).unwrap_or(&f).display().to_string();
|
||||
for (id, how) in bodies {
|
||||
let record: Record = serde_json::from_value(records[&id].clone()).unwrap();
|
||||
one(
|
||||
&format!("{short}:{id}"),
|
||||
&record,
|
||||
how["trusted"].as_bool().unwrap_or(true),
|
||||
&fixes,
|
||||
&mut t,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut report = String::new();
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"bodies {} identical {} rounding-only {} (places {}, largest {:e}) differing {} refused in {} refused out {}",
|
||||
t.bodies,
|
||||
t.identical,
|
||||
t.rounding_only,
|
||||
t.rounding_places,
|
||||
t.rounding_largest,
|
||||
t.bodies - t.identical - t.rounding_only - t.refused_in.len() - t.refused_out.len(),
|
||||
t.refused_in.len(),
|
||||
t.refused_out.len()
|
||||
);
|
||||
for (class, c) in &t.classes {
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"CLASS {class} | bodies {} places {}",
|
||||
c.bodies, c.places
|
||||
);
|
||||
for e in &c.examples {
|
||||
let _ = writeln!(report, " {e}");
|
||||
}
|
||||
}
|
||||
for n in &t.sheet_side {
|
||||
let _ = writeln!(report, "SHEET-SIDE {n}");
|
||||
}
|
||||
for (n, classes) in &t.per_body {
|
||||
let list: Vec<&str> = classes.iter().map(String::as_str).collect();
|
||||
let _ = writeln!(report, "BODY {n} | {}", list.join(" ; "));
|
||||
}
|
||||
for w in &t.tolerance_wrong {
|
||||
let _ = writeln!(report, "TOLERANCE-WRONG {w}");
|
||||
}
|
||||
for (n, f, a, b, trusted) in &t.relabelled {
|
||||
let _ = writeln!(report, "RELABEL {n} face {f}: {a} -> {b} trusted {trusted}");
|
||||
}
|
||||
for r in &t.refused_in {
|
||||
let _ = writeln!(report, "REFUSED-IN {r}");
|
||||
}
|
||||
for r in &t.refused_out {
|
||||
let _ = writeln!(report, "REFUSED-OUT {r}");
|
||||
}
|
||||
if let Ok(p) = std::env::var("PARITY_OUT") {
|
||||
std::fs::write(p, &report).unwrap();
|
||||
}
|
||||
eprintln!("{}", report.lines().next().unwrap_or(""));
|
||||
assert!(t.bodies > 0);
|
||||
assert!(t.refused_in.is_empty(), "{:#?}", t.refused_in);
|
||||
assert!(t.refused_out.is_empty(), "{:#?}", t.refused_out);
|
||||
assert!(t.tolerance_wrong.is_empty(), "{:#?}", t.tolerance_wrong);
|
||||
assert!(t.sheet_geometry.is_empty(), "{:#?}", t.sheet_geometry);
|
||||
let pinned = pinned();
|
||||
let seen: BTreeMap<&String, &BTreeSet<String>> = t.per_body.iter().collect();
|
||||
for (b, classes) in &seen {
|
||||
assert_eq!(pinned.get(*b), Some(*classes), "{b}: classes not as pinned");
|
||||
}
|
||||
if stride == 1 {
|
||||
let missing: Vec<&String> = pinned.keys().filter(|b| !seen.contains_key(b)).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"pinned bodies that no longer differ: {missing:#?}"
|
||||
);
|
||||
// The corpus grows as cases are added, so the totals aren't pinned: every body that
|
||||
// differs is pinned above, and the rest must be identical or within rounding.
|
||||
assert_eq!(
|
||||
(t.bodies - t.identical - t.rounding_only, t.rounding_only),
|
||||
(pinned.len(), 36),
|
||||
"bodies that differ, bodies within rounding"
|
||||
);
|
||||
// the largest rounding difference, relative (#3137: 4.885e-16)
|
||||
assert!(t.rounding_largest <= 4.9e-16, "{:e}", t.rounding_largest);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole corpus (`PARITY_STRIDE` takes every nth entry; the run takes seconds in release).
|
||||
#[test]
|
||||
fn every_corpus_body_comes_back_as_the_flat_export_wrote_it() {
|
||||
parity(
|
||||
std::env::var("PARITY_STRIDE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! (#3185) An analytic label is kept on its NURBS form when the kernel's analytic surface,
|
||||
//! carrying the label's numbers, evaluates as the form within the rounding
|
||||
//! `AnalyticSupport::build` derives term by term (`surface::implicit`'s `evaluates_as`):
|
||||
//! [`AnalyticSupport::parametrisation_margin`] is the largest ratio of distance to bound over
|
||||
//! the form's Greville points, at most 1 where the label stands.
|
||||
//!
|
||||
//! - The corpus labels #3137's parity run refused under the old constant `gamma(16 (p + q +
|
||||
//! 2)) size`, each pinned to its margin as measured under the derived bound: the three tori
|
||||
//! of b6 and b85 (0.02 to 0.50) stand; b46's torus (1.69) and the four cylinders (b94 1.23,
|
||||
//! b43 1.40, the 2903 rims 1.52 and 2.03) do not: their frames are off their forms past
|
||||
//! the rounding at their size. Since #3199 the kernel's recognised sweep frame is the form's
|
||||
//! own (its end meridians), so a label is measured against the form, not against a fit
|
||||
//! that shared the label's error.
|
||||
//! - A torus the kernel makes at the groove's sizes stands on its own form, and the same label
|
||||
//! turned about its axis by four times what the bound allows is refused.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::print_stderr,
|
||||
clippy::disallowed_methods
|
||||
)] // test (the turn's cos and sin)
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore::blend::fillet_section::station::as_b_spline;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
|
||||
use solidcore::surface::from_parameters::from_parameters;
|
||||
use solidcore::surface::implicit::AnalyticSupport;
|
||||
use solidcore::surface::surface::{Surface, SurfaceKind};
|
||||
use solidcore::surface::torus::{Torus, TorusOptions};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn corpus() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../solidcore/tests/kernel_cases")
|
||||
}
|
||||
|
||||
/// The corpus's kernel, its fixes on (as the parity run and SolidCraft run it).
|
||||
fn kernel() -> Kernel {
|
||||
let v: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(corpus().join("fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
let mut fixes = UpstreamFixes::default();
|
||||
for flag in v.as_array().unwrap() {
|
||||
*fixes.flag_mut(flag.as_str().unwrap()).unwrap() = true;
|
||||
}
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = fixes;
|
||||
k
|
||||
}
|
||||
|
||||
fn body(case: &str, id: &str) -> Record {
|
||||
let gz = corpus().join(format!("{case}.bodies.json.gz"));
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(std::fs::File::open(&gz).unwrap())
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
serde_json::from_value(records[id].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn placement(f: &solidcore::eval::Frame) -> Axis2Placement {
|
||||
let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
|
||||
Axis2Placement::from_stored(
|
||||
Point3::new(f.origin[0], f.origin[1], f.origin[2]),
|
||||
v(f.x_axis),
|
||||
v(f.y_axis),
|
||||
)
|
||||
}
|
||||
|
||||
/// A corpus face's label as the kernel's support, and its NURBS form.
|
||||
fn label_and_form(
|
||||
k: &Kernel,
|
||||
case: &str,
|
||||
id: &str,
|
||||
face: usize,
|
||||
) -> (
|
||||
AnalyticSupport,
|
||||
solidcore::nurbs::b_spline_surface::BSplineSurface,
|
||||
) {
|
||||
let r = body(case, id);
|
||||
let f = &r.faces[face];
|
||||
let nurbs = solidcore_convert::surface(f.nurbs.as_ref().unwrap()).unwrap();
|
||||
let (s, _) = from_parameters(k, &nurbs, f.domain).unwrap();
|
||||
let SurfaceKind::BSpline(form) = s.kind() else {
|
||||
panic!("{case} {id} {face}: the form is not a B-spline")
|
||||
};
|
||||
let support = match solidcore_convert::surface(&f.surface).unwrap() {
|
||||
solidcore::eval::Surface::Torus {
|
||||
frame,
|
||||
major_radius,
|
||||
minor_radius,
|
||||
..
|
||||
} => AnalyticSupport::Torus {
|
||||
placement: placement(&frame),
|
||||
major_radius,
|
||||
minor_radius,
|
||||
},
|
||||
solidcore::eval::Surface::Cylinder { frame, radius } => AnalyticSupport::Cylinder {
|
||||
placement: placement(&frame),
|
||||
radius,
|
||||
},
|
||||
other => panic!("{case} {id} {face}: {other:?}"),
|
||||
};
|
||||
(support, form.copy())
|
||||
}
|
||||
|
||||
/// (case, body, face, the margin measured at #3185, whether the label stands)
|
||||
const CASES: &[(&str, &str, usize, f64, bool)] = &[
|
||||
(
|
||||
"sweeps/3897-sweep-ring-along-a-short-arc-joined-to-a-collar",
|
||||
"b6",
|
||||
1,
|
||||
0.0201,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b46",
|
||||
1,
|
||||
1.6943,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b85",
|
||||
15,
|
||||
0.4995,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"sweeps/4280-circular-pattern-of-sweep-cut-groove-on-hub",
|
||||
"b85",
|
||||
21,
|
||||
0.4245,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"booleans/3456-deep-cut-through-drilled-taper-leaves-base",
|
||||
"b94",
|
||||
3,
|
||||
1.2339,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"crashes/2243-step-export-flanged-enclosure",
|
||||
"b43",
|
||||
1,
|
||||
1.4008,
|
||||
false,
|
||||
),
|
||||
("fillets/2903-original-rim-chamfer", "b0", 1, 1.5243, false),
|
||||
("fillets/2903-original-rim-chamfer", "b0", 3, 2.0275, false),
|
||||
("fillets/2903-original-rim-round", "b0", 1, 1.5243, false),
|
||||
("fillets/2903-original-rim-round", "b0", 3, 2.0275, false),
|
||||
];
|
||||
|
||||
/// Each corpus label's margin, pinned to 1 % of what #3185 measured, on its side of 1, and
|
||||
/// `build` agreeing with it.
|
||||
#[test]
|
||||
fn the_corpus_labels_stand_or_fall_by_the_derived_bound() {
|
||||
let k = kernel();
|
||||
let mut report = String::new();
|
||||
let mut wrong = Vec::new();
|
||||
for &(case, id, face, pinned, stands) in CASES {
|
||||
let (support, form) = label_and_form(&k, case, id, face);
|
||||
let m = support.parametrisation_margin(&k, &form).unwrap().unwrap();
|
||||
let _ = writeln!(report, "{case} {id} face {face}: margin {m:.4}");
|
||||
let built = support.build(&k, form).is_ok();
|
||||
if (m <= 1.0) != stands || built != stands || (m - pinned).abs() > 0.01 * pinned {
|
||||
wrong.push(format!(
|
||||
"{case} {id} face {face}: margin {m} (pinned {pinned}, stands {stands}), build {built}"
|
||||
));
|
||||
}
|
||||
}
|
||||
eprintln!("{report}");
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// A torus at the groove's sizes (#3185's b46: centre (19.375, -33.558, 37.5), R 33.657, r 2,
|
||||
/// a 43.6 degree sweep of the whole tube), made by the kernel: its own label stands on its
|
||||
/// form; turned about its axis by four times the turn the bound allows (the margin is linear
|
||||
/// in a small turn: a turn of `t` reads `m(t)`, so `4 t / m(t)` reads 4), it is refused.
|
||||
#[test]
|
||||
fn a_torus_label_turned_past_its_bound_is_refused() {
|
||||
let k = kernel();
|
||||
let o = Point3::new(19.374_999_999_999_975, -33.558_484_396_647_01, 37.5);
|
||||
let x = Vec3::new(
|
||||
-0.427_099_277_807_218_7,
|
||||
0.739_757_649_038_078_7,
|
||||
-0.519_946_946_895_745_4,
|
||||
);
|
||||
let y = Vec3::new(
|
||||
0.259_973_473_447_872_36,
|
||||
-0.450_287_264_631_874_2,
|
||||
-0.854_198_555_614_438_5,
|
||||
);
|
||||
let domain = Extent2d::from_bounds(0.0, -180.0, 43.602_818_972_703_645, 180.0);
|
||||
let made = Torus::new(
|
||||
&k,
|
||||
o,
|
||||
x,
|
||||
y,
|
||||
domain,
|
||||
33.657_280_044_590_65,
|
||||
2.0,
|
||||
TorusOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let s = Surface::from(made);
|
||||
let form = as_b_spline(&k, &s).unwrap().unwrap().into_owned();
|
||||
let own = AnalyticSupport::of(&s).unwrap();
|
||||
let m0 = own.parametrisation_margin(&k, &form).unwrap().unwrap();
|
||||
assert!(m0 <= 1.0, "own label: {m0}");
|
||||
assert!(own.build(&k, form.copy()).is_ok());
|
||||
let turned = |t: f64| {
|
||||
let AnalyticSupport::Torus {
|
||||
placement,
|
||||
major_radius,
|
||||
minor_radius,
|
||||
} = own
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let (c, sn) = (t.cos(), t.sin());
|
||||
let (px, py) = (placement.x_axis(), placement.y_axis());
|
||||
AnalyticSupport::Torus {
|
||||
placement: Axis2Placement::from_stored(
|
||||
placement.origin(),
|
||||
px * c + py * sn,
|
||||
py * c - px * sn,
|
||||
),
|
||||
major_radius,
|
||||
minor_radius,
|
||||
}
|
||||
};
|
||||
let probe = 1e-11;
|
||||
let m1 = turned(probe)
|
||||
.parametrisation_margin(&k, &form)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(m1 > 1.0, "a turn of {probe} rad reads {m1}");
|
||||
let four = 4.0 * probe / m1;
|
||||
let m4 = turned(four)
|
||||
.parametrisation_margin(&k, &form)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
eprintln!("own {m0:.4}; turn {probe:e}: {m1:.3}; turn {four:e}: {m4:.3}");
|
||||
assert!(m4 > 1.0, "a turn of {four:e} rad reads {m4}");
|
||||
assert!(turned(four).build(&k, form).is_err());
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! [`Order`]'s inverse and its pieces (#3137): what SolidCraft's `history::Order` gave, read off
|
||||
//! the conversion's order. A body whose solids touch along an edge comes back with one record
|
||||
//! edge per pair of faces that edge separates: each is a piece of that kernel edge.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)] // test
|
||||
|
||||
use solidcore::boolean::api_brep::boolean_union;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::math::Point3;
|
||||
use solidcore::primitive::api_primitives::create_box;
|
||||
use solidcore_convert::brep::to_record;
|
||||
|
||||
/// Two unit boxes touching along one edge (the line x = y = 1): one body, its shared edge one
|
||||
/// kernel edge with four uses, two record edges.
|
||||
#[test]
|
||||
fn an_edge_solids_touch_along_is_two_pieces() {
|
||||
let mut k = Kernel::default();
|
||||
let a = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
|
||||
let c = create_box(&mut k, Point3::new(1.0, 1.0, 0.0), 1.0, 1.0, 1.0).unwrap();
|
||||
let mut b = boolean_union(&mut k, a, c).unwrap();
|
||||
let (record, order) = to_record(&mut k, &mut b).unwrap();
|
||||
let pieces = order.pieces(&record).unwrap();
|
||||
assert_eq!(pieces.len(), 2, "{pieces:?}");
|
||||
assert_eq!(pieces[0].kernel, pieces[1].kernel);
|
||||
assert_ne!(pieces[0].edge, pieces[1].edge);
|
||||
for p in &pieces {
|
||||
assert_eq!(p.faces.len(), 2, "{p:?}");
|
||||
assert_eq!(order.edges[p.edge as usize], p.kernel);
|
||||
// the faces are those of that record edge's own uses
|
||||
let mut uses: Vec<u32> = record.edges[p.edge as usize]
|
||||
.uses
|
||||
.iter()
|
||||
.map(|u| u.face)
|
||||
.collect();
|
||||
uses.sort_unstable();
|
||||
uses.dedup();
|
||||
assert_eq!(p.faces, uses, "{p:?}");
|
||||
}
|
||||
// the two pieces bound four different faces, two of each box
|
||||
let mut all: Vec<u32> = pieces.iter().flat_map(|p| p.faces.clone()).collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
assert_eq!(all.len(), 4);
|
||||
// the inverses name each entity's first record index
|
||||
for (i, f) in order.faces.iter().enumerate() {
|
||||
assert_eq!(order.record_faces()[f] as usize, i);
|
||||
}
|
||||
let first = order.record_edges()[&pieces[0].kernel];
|
||||
assert_eq!(first, pieces[0].edge.min(pieces[1].edge));
|
||||
for (i, v) in order.vertices.iter().enumerate() {
|
||||
assert!(order.record_vertices()[v] as usize <= i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body with no touching edge has no pieces.
|
||||
#[test]
|
||||
fn a_box_has_no_pieces() {
|
||||
let mut k = Kernel::default();
|
||||
let mut b = create_box(&mut k, Point3::ZERO, 1.0, 1.0, 1.0).unwrap();
|
||||
let (record, order) = to_record(&mut k, &mut b).unwrap();
|
||||
assert!(order.pieces(&record).unwrap().is_empty());
|
||||
// one record edge per kernel edge here; in general there are at most as many kernel edges
|
||||
// as record edges (a touching edge is copied)
|
||||
assert!(order.record_edges().len() <= record.edges.len());
|
||||
assert_eq!(order.record_edges().len(), record.edges.len());
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
rust-version.workspace = true
|
||||
# solidcore-record needs Rust 1.88 (edition 2024 let chains).
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/tangency-labs/solidcore"
|
||||
keywords = ["cad", "brep", "nurbs", "modelling"]
|
||||
categories = ["science"]
|
||||
|
||||
+5
-7
@@ -824,13 +824,11 @@ fn overflow_first(
|
||||
}
|
||||
// a round mitred to one that goes first goes with it, through every mitre
|
||||
let place = |e: &EdgeId| edges.iter().position(|p| p == e);
|
||||
let pairs: Vec<(usize, usize)> = mitred
|
||||
// two by two (an odd last one is no pair); the chunk size 2 is not 0: cannot panic
|
||||
.chunks_exact(2)
|
||||
.filter_map(|pair| match pair {
|
||||
[a, b] => Some((place(a)?, place(b)?)),
|
||||
_ => None,
|
||||
})
|
||||
// two by two (an odd last one is no pair)
|
||||
let (mitred_pairs, _) = mitred.as_chunks::<2>();
|
||||
let pairs: Vec<(usize, usize)> = mitred_pairs
|
||||
.iter()
|
||||
.filter_map(|[a, b]| Some((place(a)?, place(b)?)))
|
||||
.collect();
|
||||
let mut grew = true;
|
||||
while grew {
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
//! Contact between bodies: whether two bodies meet (share material or touch within a
|
||||
//! tolerance) and the distance between their boundaries, each decided by the kernel's
|
||||
//! certified bracket ([`solidcore::query::contact`]). Ops passes the tolerance on as the
|
||||
//! caller gives it and reads the kernel's verdict; it computes no distance of its own.
|
||||
|
||||
use solidcore::geom::certified::Verdict;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::query::contact::{self as c, Contact, Prepared};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
use solidcore::topology::Brep;
|
||||
use solidcore::Point3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::session::{run_on, Answer};
|
||||
use crate::{Error, Result};
|
||||
|
||||
impl Answer for Contact {}
|
||||
impl Answer for Vec<Contact> {}
|
||||
|
||||
/// The bracket of the distance between two bodies' boundaries, at most the width asked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Gap {
|
||||
/// No point of either boundary is nearer the other than this.
|
||||
pub lower: f64,
|
||||
/// The distance of the witness points, rounded up.
|
||||
pub upper: f64,
|
||||
/// A point on each body `upper` apart, when one is known (the first on this body).
|
||||
pub witness: Option<[[f64; 3]; 2]>,
|
||||
}
|
||||
|
||||
fn array(p: Point3) -> [f64; 3] {
|
||||
[p.x, p.y, p.z]
|
||||
}
|
||||
|
||||
/// A copy of `body`'s kernel body, the body released at once: the contact test takes its
|
||||
/// operands mutably (for its classifications' caches) and never edits the caller's, and a
|
||||
/// body tested against itself is locked only once at a time.
|
||||
fn copy_of(k: &mut Kernel, body: &Body) -> solidcore::Result<Brep> {
|
||||
let b = body.lock();
|
||||
brep_copy(k, &b)
|
||||
}
|
||||
|
||||
/// The verdict as an answer: `Yes` meets, `No` does not, `Undecided` refused with the
|
||||
/// bracket, which names the tolerance that would decide.
|
||||
fn decided(contact: &Contact, other: usize) -> Result<bool> {
|
||||
match contact.verdict {
|
||||
Verdict::Yes => Ok(true),
|
||||
Verdict::No => Ok(false),
|
||||
Verdict::Undecided => Err(Error::ContactUndecided {
|
||||
other,
|
||||
lower: contact.lower,
|
||||
upper: contact.upper,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn poisoned(bodies: &[&Body]) -> Result<()> {
|
||||
if bodies.iter().any(|b| b.is_poisoned()) {
|
||||
return Err(Error::Internal(
|
||||
"contact: a body was left unfinished by an earlier panic".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// Whether this body and `other` meet at `tol` (the caller's: ops invents none): share
|
||||
/// material, or have boundaries within `tol` of each other
|
||||
/// ([`solidcore::query::contact::meets`]). Neither body changes; `other` may be this body.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] when the kernel's bracket does not decide it (`other` 0):
|
||||
/// the bracket, never read as either answer; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
|
||||
/// tolerance not finite and positive or a body without faces; those of the session.
|
||||
pub fn meets(&self, other: &Body, tol: f64) -> Result<bool> {
|
||||
poisoned(&[self, other])?;
|
||||
let contact = run_on("contact::meets", &[("a", self), ("b", other)], |k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let mut b = copy_of(k, other)?;
|
||||
c::meets(k, &mut a, &mut b, tol)
|
||||
})?;
|
||||
decided(&contact, 0)
|
||||
}
|
||||
|
||||
/// [`Body::meets`] against each of `others`, each with its own tolerance, this body
|
||||
/// prepared once (its faces' pieces about the centre of its box,
|
||||
/// [`Prepared::about`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] for the first of `others` the kernel's bracket does not
|
||||
/// decide, by its index; those of [`Body::meets`].
|
||||
pub fn meets_each(&self, others: &[(&Body, f64)]) -> Result<Vec<bool>> {
|
||||
let mut bodies: Vec<&Body> = vec![self];
|
||||
bodies.extend(others.iter().map(|(b, _)| *b));
|
||||
poisoned(&bodies)?;
|
||||
let inputs: Vec<(&'static str, &Body)> = bodies.iter().map(|b| ("brep", *b)).collect();
|
||||
let contacts = run_on("contact::meets_each", &inputs, |k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let prepared = Prepared::about(k, &a)?;
|
||||
let mut contacts = Vec::new();
|
||||
for (other, tol) in others {
|
||||
let mut b = copy_of(k, other)?;
|
||||
contacts.push(c::meets_prepared(k, &mut a, &prepared, &mut b, *tol)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
})?;
|
||||
contacts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, contact)| decided(contact, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The distance between this body's boundary and `other`'s, bracketed to at most `width`
|
||||
/// (relative to its upper end, absolute below 1;
|
||||
/// [`solidcore::query::contact::boundary_distance`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::ContactUndecided`] (`other` 0) with the narrowest bracket reached, when the
|
||||
/// kernel's work runs out before `width`; [`Error::Status`] `SM_ERR_INVALID_INPUT` for a
|
||||
/// width not finite and positive or a body without faces; those of the session.
|
||||
pub fn boundary_distance(&self, other: &Body, width: f64) -> Result<Gap> {
|
||||
poisoned(&[self, other])?;
|
||||
let contact = run_on(
|
||||
"contact::boundary_distance",
|
||||
&[("a", self), ("b", other)],
|
||||
|k| {
|
||||
let mut a = copy_of(k, self)?;
|
||||
let mut b = copy_of(k, other)?;
|
||||
c::boundary_distance(k, &mut a, &mut b, width)
|
||||
},
|
||||
)?;
|
||||
decided(&contact, 0)?;
|
||||
Ok(Gap {
|
||||
lower: contact.lower,
|
||||
upper: contact.upper,
|
||||
witness: contact.witness.map(|[p, q]| [array(p), array(q)]),
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
@@ -134,6 +134,19 @@ pub enum Error {
|
||||
/// The kernel found its own state inconsistent part way through a call, or was called
|
||||
/// before the preparation it needs.
|
||||
Exception,
|
||||
/// A contact test the kernel's certified bracket of the boundary distance does not decide
|
||||
/// (`solidcore::query::contact`): the tolerance lies inside `[lower, upper]` (or the
|
||||
/// boundaries are apart but one body's enclosure of the other is not decided), or a
|
||||
/// distance's bracket is still wider than asked. Never read as either answer. `other` is
|
||||
/// the index of the body tested among the call's others.
|
||||
ContactUndecided {
|
||||
/// The other body's index among the call's (0 for one).
|
||||
other: usize,
|
||||
/// No point of either boundary is nearer the other than this.
|
||||
lower: f64,
|
||||
/// A pair of points, one on each boundary, is this far apart.
|
||||
upper: f64,
|
||||
},
|
||||
/// The application cancelled the call.
|
||||
Cancelled,
|
||||
/// Ops was called from inside a kernel call or from the failure callback.
|
||||
@@ -325,6 +338,14 @@ impl std::fmt::Display for Error {
|
||||
),
|
||||
Error::TooFar { largest } => write!(f, "too far to extend (largest {largest:?})"),
|
||||
Error::Exception => write!(f, "solidcore raised an internal exception"),
|
||||
Error::ContactUndecided {
|
||||
other,
|
||||
lower,
|
||||
upper,
|
||||
} => write!(
|
||||
f,
|
||||
"contact with body {other} is undecided: the boundary distance is in [{lower}, {upper}]"
|
||||
),
|
||||
Error::Cancelled => write!(f, "cancelled"),
|
||||
Error::Reentered => write!(
|
||||
f,
|
||||
|
||||
+9
-2
@@ -19,8 +19,8 @@
|
||||
//! - [`provenance`]: the hook an application's construction history rides on.
|
||||
//!
|
||||
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
|
||||
//! distances and normals), [`boolean`], [`stitch`], [`prism`] (planar region booleans and
|
||||
//! extrusion, prism sections).
|
||||
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
|
||||
//! [`boolean`], [`stitch`], [`prism`] (planar region booleans and extrusion, prism sections).
|
||||
//!
|
||||
//! Operations change the body they are given, as the kernel does. An application that keeps
|
||||
//! its input copies it first ([`Body::copy`]).
|
||||
@@ -32,6 +32,7 @@ pub mod blend;
|
||||
pub mod body;
|
||||
pub mod boolean;
|
||||
pub mod check;
|
||||
pub mod contact;
|
||||
pub mod error;
|
||||
pub mod extend;
|
||||
pub mod file;
|
||||
@@ -42,8 +43,10 @@ pub mod prism;
|
||||
pub mod provenance;
|
||||
pub mod session;
|
||||
pub mod split;
|
||||
pub mod step_assembly;
|
||||
pub mod stitch;
|
||||
pub mod sweep;
|
||||
pub mod tangent;
|
||||
pub mod untrim;
|
||||
|
||||
pub use blend::{
|
||||
@@ -54,6 +57,7 @@ pub use blend::{
|
||||
pub use body::{make_box, make_cone, make_cylinder, make_sphere, make_torus, Body};
|
||||
pub use boolean::BooleanOp;
|
||||
pub use check::{CheckLevel, CheckReport};
|
||||
pub use contact::Gap;
|
||||
pub use error::{Error, FitKind, Result, RoomReason, Unblendable};
|
||||
pub use measure::{
|
||||
point_distance, Distance, EdgePoint, FaceCentroid, GapSample, MassPropertiesEstimate, Normal,
|
||||
@@ -61,7 +65,10 @@ pub use measure::{
|
||||
};
|
||||
pub use offset::{draft, offset_faces, offset_sheet, shell, thicken};
|
||||
pub use order::Order;
|
||||
pub use step_assembly::{placed_bodies, PlacedBody};
|
||||
|
||||
#[cfg(test)]
|
||||
mod noisy_round_tests;
|
||||
#[cfg(test)]
|
||||
mod test_corpus;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! #3066: a round on supports read from a record with rounding in their frames keeps its exact
|
||||
//! definition. The class is decided within the body's tolerance; the round lies on a torus of
|
||||
//! its own, its net certified there at bound 0, and that torus's distance from the class's
|
||||
//! torus from the supports is held to the body's tolerance, so the round answers
|
||||
//! `procedural_blend()`.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing,
|
||||
clippy::disallowed_methods
|
||||
)] // test
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcore::surface::surface::SurfaceKind;
|
||||
use solidcore::topology::face::p2 as face;
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
use crate::blend::{blend, Blend, Corners};
|
||||
use crate::boolean::boolean;
|
||||
use crate::{make_box, make_cylinder, session, Body, BooleanOp, CheckLevel};
|
||||
|
||||
fn fixes() {
|
||||
let fixes: Vec<String> = serde_json::from_str(include_str!(
|
||||
"../../solidcore/tests/kernel_cases/fixes.json"
|
||||
))
|
||||
.unwrap();
|
||||
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
|
||||
}
|
||||
|
||||
/// The faces of `b` that are no plane, as (with a definition, without one).
|
||||
fn definitions(b: &Body) -> (usize, usize) {
|
||||
let brep = b.lock();
|
||||
let (mut with, mut without) = (0, 0);
|
||||
for &f in brep.face_list() {
|
||||
let s = face::surface(&brep, f).unwrap().unwrap();
|
||||
if matches!(s.kind(), SurfaceKind::Plane(_)) {
|
||||
continue;
|
||||
}
|
||||
if s.procedural_blend().is_some() {
|
||||
with += 1;
|
||||
} else {
|
||||
without += 1;
|
||||
}
|
||||
}
|
||||
(with, without)
|
||||
}
|
||||
|
||||
/// The edge of `b` through `p`.
|
||||
fn edge_through(b: &Body, p: [f64; 3]) -> usize {
|
||||
let k = solidcore::Kernel::default();
|
||||
let brep = b.lock();
|
||||
let q = solidcore::Vec3::new(p[0], p[1], p[2]);
|
||||
brep.edge_list()
|
||||
.iter()
|
||||
.position(|&e| {
|
||||
solidcore::query::api_queries::edge_closest_point(&k, &brep, e, q)
|
||||
.unwrap()
|
||||
.closest
|
||||
.distance
|
||||
< 1e-6
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A boss R 8 standing 10 out of a 40 x 40 x 10 block, its axis turned 1e-10 off square to the
|
||||
/// block's top, its root rounded 2: a circle-class round on supports only that close to the
|
||||
/// class (the error the 1e-10 turn makes over the part is far inside the body's tolerance, and
|
||||
/// far outside the rounding the net is certified to). The round is concave: the corner between
|
||||
/// the wall and the top, less the quarter disc, turned about the axis. By Pappus, it adds
|
||||
/// 2 pi (R + x) r^2 (1 - pi / 4), x = r (10 - 3 pi) / (12 - 3 pi) the corner's centroid from the
|
||||
/// wall.
|
||||
#[test]
|
||||
fn a_round_on_a_boss_tilted_off_the_class_keeps_its_definition() {
|
||||
fixes();
|
||||
let block = make_box([0.0; 3], [40.0, 40.0, 10.0]).unwrap();
|
||||
// the boss from z = 5 to 20, its axis turned 1e-10 about x off square to the block's top
|
||||
let boss = make_cylinder([0.0; 3], 8.0, 15.0).unwrap();
|
||||
let (c, s) = (1e-10_f64.cos(), 1e-10_f64.sin());
|
||||
boss.transform(&[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, c, s, 0.0],
|
||||
[0.0, -s, c, 0.0],
|
||||
[20.0, 20.0, 5.0, 1.0],
|
||||
])
|
||||
.unwrap();
|
||||
let b = boolean(block, boss, BooleanOp::Union).unwrap().unwrap();
|
||||
let e = edge_through(&b, [28.0, 20.0, 10.0]);
|
||||
let (r, big_r) = (2.0, 8.0);
|
||||
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled).unwrap();
|
||||
let report = b.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "{}", report.text);
|
||||
// the boss's wall and the round: the round keeps its definition
|
||||
assert_eq!(definitions(&b), (1, 1));
|
||||
let x = r * (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI);
|
||||
let want =
|
||||
16000.0 + PI * big_r * big_r * 10.0 + 2.0 * PI * (big_r + x) * r * r * (1.0 - PI / 4.0);
|
||||
let got = b.volume(1e-10).unwrap();
|
||||
assert!((got - want).abs() <= 1e-9 * want, "{got} {want}");
|
||||
}
|
||||
|
||||
/// SolidCraft's raked wedge (`tests/data/raked_wedge_noisy_cylinders.json`): its end outline's
|
||||
/// rounds keep their definitions, the two circle-class rounds on the read-back cylinders among
|
||||
/// them; the operand's own faces, read from the record, have none.
|
||||
#[test]
|
||||
fn the_raked_wedges_rounds_keep_their_definitions() {
|
||||
fixes();
|
||||
let record: Record = serde_json::from_str(include_str!(
|
||||
"../tests/data/raked_wedge_noisy_cylinders.json"
|
||||
))
|
||||
.unwrap();
|
||||
let (b, _) = Body::from_record(&record, true).unwrap();
|
||||
let (_, before) = definitions(&b);
|
||||
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
|
||||
let mut seeds = Vec::new();
|
||||
for x in [-69.0, 69.0] {
|
||||
for p in [
|
||||
[x, -41.0, 22.0],
|
||||
[x, 8.0, 41.0],
|
||||
[x, (38.0 + top) / 2.0, 60.0],
|
||||
] {
|
||||
seeds.push(edge_through(&b, p));
|
||||
}
|
||||
}
|
||||
let chain = crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap();
|
||||
blend(&b, Blend::Round, &chain, &[0.854], &[], Corners::Filled).unwrap();
|
||||
let report = b.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "{}", report.text);
|
||||
let (with, without) = definitions(&b);
|
||||
// as many of the new rounds keep their definitions as on the same wedge with exact
|
||||
// cylinders, and the rest are as bare there
|
||||
let (exact_with, exact_bare) = exact_wedge_rounds();
|
||||
assert_eq!((with, without - before), (exact_with, exact_bare));
|
||||
assert_eq!(with + without - before, chain.len());
|
||||
}
|
||||
|
||||
/// The same wedge built here, its cylinders exact, its end outline rounded as SolidCraft's: of the
|
||||
/// rounds that last call makes, how many keep a definition and how many don't.
|
||||
fn exact_wedge_rounds() -> (usize, usize) {
|
||||
use crate::sweep::{extrude, Curve, DraftCorner};
|
||||
let top = 60.0 - 60.0 * 10f64.to_radians().tan();
|
||||
let profile = [
|
||||
[-60.0, 0.0],
|
||||
[60.0, 0.0],
|
||||
[top, 60.0],
|
||||
[38.0, 60.0],
|
||||
[-22.0, 22.0],
|
||||
[-60.0, 22.0],
|
||||
];
|
||||
let at = |p: [f64; 2]| [-69.0, p[0], p[1]];
|
||||
let curves: Vec<Curve> = (0..6)
|
||||
.map(|i| Curve::Line {
|
||||
start: at(profile[i]),
|
||||
end: at(profile[(i + 1) % 6]),
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(
|
||||
&curves,
|
||||
[1.0, 0.0, 0.0],
|
||||
138.0,
|
||||
0.0,
|
||||
DraftCorner::Curvature,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let round = |pts: &[[f64; 3]], r: f64, chain: bool| {
|
||||
let seeds: Vec<usize> = pts.iter().map(|&p| edge_through(&b, p)).collect();
|
||||
let edges = if chain {
|
||||
crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap()
|
||||
} else {
|
||||
seeds
|
||||
};
|
||||
blend(&b, Blend::Round, &edges, &[r], &[], Corners::Filled).unwrap();
|
||||
};
|
||||
round(&[[0.0, -60.0, 22.0]], 1.2, true);
|
||||
round(&[[0.0, -22.0, 22.0]], 1.5, true);
|
||||
round(&[[0.0, 38.0, 60.0]], 1.5, true);
|
||||
let back = (60.0 + top) / 2.0;
|
||||
let four = [
|
||||
[-69.0, -60.0, 12.0],
|
||||
[69.0, -60.0, 12.0],
|
||||
[-69.0, back, 30.0],
|
||||
[69.0, back, 30.0],
|
||||
];
|
||||
round(&four, 4.0, false);
|
||||
let (with_before, bare_before) = definitions(&b);
|
||||
let mut ends = Vec::new();
|
||||
for x in [-69.0, 69.0] {
|
||||
ends.extend([
|
||||
[x, -41.0, 22.0],
|
||||
[x, 8.0, 41.0],
|
||||
[x, (38.0 + top) / 2.0, 60.0],
|
||||
]);
|
||||
}
|
||||
round(&ends, 0.854, true);
|
||||
let (with, bare) = definitions(&b);
|
||||
(with - with_before, bare - bare_before)
|
||||
}
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
//! Compose a STEP product structure into placed body instances.
|
||||
//!
|
||||
//! The record reader keeps the placements exactly as the file writes them: assembly
|
||||
//! occurrences, `MAPPED_ITEM` targets, representation maps and transformation operators.
|
||||
//! This module turns those raw placements into kernel matrices, walks the product tree and
|
||||
//! returns every body with the transform that places it in the file's world frame.
|
||||
//!
|
||||
//! It computes no geometry of its own: frame and operator matrices, their products and
|
||||
//! inverses come from `solidcore::geom::step_matrix`. Body construction is the caller's:
|
||||
//! each returned [`PlacedBody::entity`] is a STEP entity id and every occurrence of a shared
|
||||
//! body is emitted separately with its own transform.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use solidcore::geom::step_matrix::{
|
||||
concat, frame_matrix, inverse, operator_matrix, Matrix4, IDENTITY,
|
||||
};
|
||||
use solidcore_record::step::geom::Placement;
|
||||
use solidcore_record::step::product::{
|
||||
Frame, ItemKind, Link, Occurrence, ProductStructure, Transformation, MAX_ASSEMBLY_DEPTH,
|
||||
MAX_VISITS,
|
||||
};
|
||||
|
||||
/// One body as it appears in the expanded product structure.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlacedBody {
|
||||
/// The STEP entity id of the body (`MANIFOLD_SOLID_BREP`, …).
|
||||
pub entity: u64,
|
||||
/// The body's placement in the file's world frame, in millimetres.
|
||||
pub transform: Matrix4,
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
structure: &'a ProductStructure,
|
||||
/// Representations reachable from each definition's own shapes through plain
|
||||
/// representation relationships.
|
||||
closure: Vec<Vec<usize>>,
|
||||
/// Child occurrences of each definition, in file order.
|
||||
children: Vec<Vec<usize>>,
|
||||
/// The definitions on the current path, to refuse a cycle.
|
||||
path: Vec<usize>,
|
||||
visits: usize,
|
||||
out: Vec<PlacedBody>,
|
||||
}
|
||||
|
||||
/// Expand the product structure and return every body with its world placement.
|
||||
///
|
||||
/// A root definition is placed by the identity. Each occurrence multiplies the child's
|
||||
/// placement by the occurrence transformation, and each mapped item places the mapped
|
||||
/// representation by `target · origin⁻¹`. Refuses cycles and past the record's depth or
|
||||
/// instance bounds.
|
||||
pub fn placed_bodies(structure: &ProductStructure) -> Result<Vec<PlacedBody>, String> {
|
||||
let mut children = vec![Vec::new(); structure.definitions.len()];
|
||||
for (index, occurrence) in structure.occurrences.iter().enumerate() {
|
||||
if let Some(list) = children.get_mut(occurrence.parent) {
|
||||
list.push(index);
|
||||
}
|
||||
}
|
||||
let closure = (0..structure.definitions.len())
|
||||
.map(|definition| representation_closure(structure, definition))
|
||||
.collect();
|
||||
let mut walk = Walk {
|
||||
structure,
|
||||
closure,
|
||||
children,
|
||||
path: Vec::new(),
|
||||
visits: 0,
|
||||
out: Vec::new(),
|
||||
};
|
||||
for &root in &structure.roots {
|
||||
walk.definition(root, IDENTITY, 0)?;
|
||||
}
|
||||
Ok(walk.out)
|
||||
}
|
||||
|
||||
fn representation_closure(structure: &ProductStructure, definition: usize) -> Vec<usize> {
|
||||
let Some(definition) = structure.definitions.get(definition) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
let mut stack = definition.shapes.clone();
|
||||
while let Some(representation) = stack.pop() {
|
||||
if !seen.insert(representation) {
|
||||
continue;
|
||||
}
|
||||
out.push(representation);
|
||||
for link in &structure.links {
|
||||
if link.transformation.is_some() {
|
||||
continue;
|
||||
}
|
||||
if link.rep_1 == representation {
|
||||
stack.push(link.rep_2);
|
||||
} else if link.rep_2 == representation {
|
||||
stack.push(link.rep_1);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
fn definition(
|
||||
&mut self,
|
||||
definition: usize,
|
||||
world: Matrix4,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if depth > MAX_ASSEMBLY_DEPTH {
|
||||
return Err("assembly nested too deeply".into());
|
||||
}
|
||||
if self.path.contains(&definition) {
|
||||
return Err("assembly cycle".into());
|
||||
}
|
||||
self.visits += 1;
|
||||
if self.visits > MAX_VISITS {
|
||||
return Err(format!("more than {MAX_VISITS} assembly instances"));
|
||||
}
|
||||
self.path.push(definition);
|
||||
let reps = self.closure.get(definition).cloned().unwrap_or_default();
|
||||
let mut walked = BTreeSet::new();
|
||||
for representation in reps {
|
||||
self.representation(representation, world, &mut walked, depth)?;
|
||||
}
|
||||
let child_definitions: Vec<usize> =
|
||||
self.children.get(definition).cloned().unwrap_or_default();
|
||||
for occurrence_index in child_definitions {
|
||||
let Some(occurrence) = self.structure.occurrences.get(occurrence_index) else {
|
||||
continue;
|
||||
};
|
||||
let child_reps = self
|
||||
.closure
|
||||
.get(occurrence.child)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let local = occurrence_matrix(self.structure, occurrence, &child_reps)?;
|
||||
self.definition(occurrence.child, concat(&world, &local), depth + 1)?;
|
||||
}
|
||||
self.path.pop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn representation(
|
||||
&mut self,
|
||||
representation: usize,
|
||||
world: Matrix4,
|
||||
walked: &mut BTreeSet<usize>,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if depth > MAX_ASSEMBLY_DEPTH {
|
||||
return Err("mapped items nested too deeply".into());
|
||||
}
|
||||
if !walked.insert(representation) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(rep) = self.structure.representations.get(representation) else {
|
||||
return Err(format!("missing representation #{representation}"));
|
||||
};
|
||||
for item in &rep.items {
|
||||
match &item.kind {
|
||||
ItemKind::Body(_) => self.out.push(PlacedBody {
|
||||
entity: item.entity,
|
||||
transform: world,
|
||||
}),
|
||||
ItemKind::Mapped { map, target } => {
|
||||
let Some(map) = self.structure.maps.get(*map) else {
|
||||
return Err(format!("missing representation map #{}", item.entity));
|
||||
};
|
||||
let target = frame(target)?;
|
||||
let origin = frame(&map.origin)?;
|
||||
let origin_inverse = inverse(&origin)
|
||||
.ok_or_else(|| format!("singular map origin #{}", map.entity))?;
|
||||
let local = concat(&target, &origin_inverse);
|
||||
let placed = concat(&world, &local);
|
||||
let mapped = map.representation;
|
||||
let mut mapped_walked = BTreeSet::new();
|
||||
self.representation(mapped, placed, &mut mapped_walked, depth + 1)?;
|
||||
for link in &self.structure.links {
|
||||
if link.transformation.is_some() {
|
||||
continue;
|
||||
}
|
||||
let other = if link.rep_1 == mapped {
|
||||
Some(link.rep_2)
|
||||
} else if link.rep_2 == mapped {
|
||||
Some(link.rep_1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(other) = other {
|
||||
self.representation(other, placed, &mut mapped_walked, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ItemKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence_matrix(
|
||||
structure: &ProductStructure,
|
||||
occurrence: &Occurrence,
|
||||
child_reps: &[usize],
|
||||
) -> Result<Matrix4, String> {
|
||||
let Some(&link_index) = occurrence.placements.first() else {
|
||||
return Ok(IDENTITY);
|
||||
};
|
||||
let Some(link) = structure.links.get(link_index) else {
|
||||
return Err(format!("missing placement link #{link_index}"));
|
||||
};
|
||||
link_matrix(link, child_reps)
|
||||
}
|
||||
|
||||
fn link_matrix(link: &Link, child_reps: &[usize]) -> Result<Matrix4, String> {
|
||||
let Some(transformation) = &link.transformation else {
|
||||
return Ok(IDENTITY);
|
||||
};
|
||||
match transformation {
|
||||
Transformation::Frame(frame_value) => frame(frame_value),
|
||||
Transformation::Items { item_1, item_2, .. } => {
|
||||
let first = frame(item_1)?;
|
||||
let second = frame(item_2)?;
|
||||
let child_is_first =
|
||||
child_reps.contains(&link.rep_1) || !child_reps.contains(&link.rep_2);
|
||||
// Row-vector convention: a child in rep_1 is placed by
|
||||
// `item_1⁻¹ · item_2`; a child in rep_2 by `item_2⁻¹ · item_1`.
|
||||
let (from, to) = if child_is_first {
|
||||
(&first, &second)
|
||||
} else {
|
||||
(&second, &first)
|
||||
};
|
||||
let inverse_from = inverse(from)
|
||||
.ok_or_else(|| format!("singular occurrence placement #{}", link.entity))?;
|
||||
Ok(concat(&inverse_from, to))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(value: &Frame) -> Result<Matrix4, String> {
|
||||
match value {
|
||||
Frame::Placement(Placement {
|
||||
origin,
|
||||
axis,
|
||||
ref_direction,
|
||||
}) => frame_matrix(*origin, *axis, *ref_direction)
|
||||
.ok_or_else(|| "degenerate placement".to_string()),
|
||||
Frame::Operator(operator) => operator_matrix(
|
||||
operator.axis1,
|
||||
operator.axis2,
|
||||
operator.axis3,
|
||||
operator.origin,
|
||||
operator.scale,
|
||||
operator.scale2,
|
||||
operator.scale3,
|
||||
)
|
||||
.ok_or_else(|| "degenerate transformation operator".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcore_record::step::geom::Placement;
|
||||
use solidcore_record::step::product::{
|
||||
BodyKind, Definition, Item, OccurrenceKind, Representation,
|
||||
};
|
||||
use solidcore_record::step::units::{Context, Units};
|
||||
|
||||
fn translation(x: f64, y: f64, z: f64) -> Frame {
|
||||
Frame::Placement(Placement {
|
||||
origin: [x, y, z],
|
||||
axis: None,
|
||||
ref_direction: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn body(entity: u64) -> Item {
|
||||
Item {
|
||||
entity,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
|
||||
}
|
||||
}
|
||||
|
||||
fn representation(entity: u64, items: Vec<Item>) -> Representation {
|
||||
Representation {
|
||||
entity,
|
||||
kind: "SHAPE_REPRESENTATION".into(),
|
||||
name: String::new(),
|
||||
items,
|
||||
context: None,
|
||||
units: Context {
|
||||
units: Units { len: 1.0, ang: 1.0 },
|
||||
uncertainty: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(entity: u64, shape: usize) -> Definition {
|
||||
Definition {
|
||||
entity,
|
||||
id: format!("def{entity}"),
|
||||
description: String::new(),
|
||||
formation: 0,
|
||||
product: 0,
|
||||
shapes: vec![shape],
|
||||
}
|
||||
}
|
||||
|
||||
fn structure() -> ProductStructure {
|
||||
ProductStructure {
|
||||
products: Vec::new(),
|
||||
definitions: vec![definition(1, 0), definition(2, 1)],
|
||||
representations: vec![
|
||||
representation(10, Vec::new()),
|
||||
representation(20, vec![body(200)]),
|
||||
],
|
||||
maps: Vec::new(),
|
||||
links: Vec::new(),
|
||||
occurrences: Vec::new(),
|
||||
roots: vec![0],
|
||||
styles: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_child_body_is_transformed_by_its_occurrence() {
|
||||
let mut structure = structure();
|
||||
structure.representations[0] = representation(10, Vec::new());
|
||||
structure.occurrences.push(Occurrence {
|
||||
entity: 30,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "occ".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 1,
|
||||
reference_designator: None,
|
||||
placements: vec![0],
|
||||
});
|
||||
structure.links.push(Link {
|
||||
entity: 40,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 41,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(2.0, 3.0, 0.0),
|
||||
}),
|
||||
});
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert_eq!(placed[0].entity, 200);
|
||||
assert_eq!(
|
||||
placed[0].transform,
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[2.0, 3.0, 0.0, 1.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_items_place_the_map_origin_onto_their_target() {
|
||||
let mut structure = structure();
|
||||
structure.representations[0] = representation(10, Vec::new());
|
||||
structure.representations[0].items.push(Item {
|
||||
entity: 100,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: translation(5.0, 0.0, 0.0),
|
||||
},
|
||||
});
|
||||
structure
|
||||
.maps
|
||||
.push(solidcore_record::step::product::RepresentationMap {
|
||||
entity: 50,
|
||||
origin: translation(1.0, 0.0, 0.0),
|
||||
representation: 1,
|
||||
});
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert_eq!(placed[0].transform[3], [4.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_occurrence_placements_compose() {
|
||||
let mut structure = structure();
|
||||
structure.definitions.push(definition(3, 2));
|
||||
structure
|
||||
.representations
|
||||
.push(representation(30, vec![body(300)]));
|
||||
structure.occurrences.extend([
|
||||
Occurrence {
|
||||
entity: 31,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "a".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 1,
|
||||
reference_designator: None,
|
||||
placements: vec![0],
|
||||
},
|
||||
Occurrence {
|
||||
entity: 32,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "b".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 1,
|
||||
child: 2,
|
||||
reference_designator: None,
|
||||
placements: vec![1],
|
||||
},
|
||||
]);
|
||||
structure.links.extend([
|
||||
Link {
|
||||
entity: 40,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 41,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(1.0, 0.0, 0.0),
|
||||
}),
|
||||
},
|
||||
Link {
|
||||
entity: 42,
|
||||
rep_1: 2,
|
||||
rep_2: 1,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 43,
|
||||
item_1: translation(0.0, 0.0, 0.0),
|
||||
item_2: translation(0.0, 2.0, 0.0),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
let placed = placed_bodies(&structure).unwrap();
|
||||
let grandchild = placed
|
||||
.iter()
|
||||
.find(|body| body.entity == 300)
|
||||
.expect("grandchild body");
|
||||
assert_eq!(grandchild.transform[3], [1.0, 2.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_is_refused() {
|
||||
let mut structure = structure();
|
||||
structure.occurrences.push(Occurrence {
|
||||
entity: 30,
|
||||
kind: OccurrenceKind::NextAssemblyUsage,
|
||||
id: "occ".into(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parent: 0,
|
||||
child: 0,
|
||||
reference_designator: None,
|
||||
placements: Vec::new(),
|
||||
});
|
||||
assert!(placed_bodies(&structure).is_err());
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
//! Tangent chains on record order: the edges a pick runs on into tangentially, and the faces
|
||||
//! joined to picked faces across smooth edges. The walks are here; every measure they stop
|
||||
//! on is the kernel's: whether an edge's faces meet tangentially all along it
|
||||
//! ([`solidcore::query::tangent_edge`]), how far one edge turns from running straight on into
|
||||
//! another at a vertex and whether an edge is convex there ([`solidcore::query::edge_turn`]).
|
||||
//!
|
||||
//! The caller's `angle` (radians) is the one tolerance: edges meeting at less than it continue
|
||||
//! one another, and faces meeting at less than it all along an edge make it smooth. Nothing
|
||||
//! here picks one (SolidCraft passes its shaded view's half a degree).
|
||||
//!
|
||||
//! - [`tangent_chain_ordered`]: the picked edges, then every edge that continues one of them
|
||||
//! within `angle` at a shared vertex, and so on. An edge does not join when it is not
|
||||
//! between two faces (a one-face edge), when it is smooth (its faces meet tangentially all
|
||||
//! along it, a seam among them), or when it turns over (convex where the edge it continues
|
||||
//! is concave, or the reverse).
|
||||
//! - [`tangent_face_chain_ordered`]: the picked faces, then every face across a smooth edge
|
||||
//! from one of them, and so on.
|
||||
//!
|
||||
//! An edge whose smoothness the kernel leaves undecided (a blend's net within its margin of
|
||||
//! the angle, [`solidcore::query::tangent_edge::Tangency::Undecided`]) joins nothing and is
|
||||
//! named in the answer.
|
||||
|
||||
use solidcore::query::edge_turn::{convex_at, turn_at};
|
||||
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
|
||||
use solidcore::topology::edge;
|
||||
use solidcore::{Brep, EdgeId, FaceId, Kernel};
|
||||
|
||||
use crate::session::with_body;
|
||||
use crate::{Body, Error, Order, Result};
|
||||
|
||||
/// What [`tangent_chain_ordered`] and [`tangent_face_chain_ordered`] answer, in record
|
||||
/// indices.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Chain {
|
||||
/// The picks (each once, in order), then what joined them, in the order it joined.
|
||||
pub members: Vec<usize>,
|
||||
/// Edges the walk met whose smoothness the kernel left undecided: they joined nothing.
|
||||
pub undecided: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A kernel edge's smoothness, decided once.
|
||||
struct Smooth {
|
||||
known: Vec<(EdgeId, Option<Tangency>)>,
|
||||
}
|
||||
|
||||
impl Smooth {
|
||||
fn of(
|
||||
&mut self,
|
||||
k: &Kernel,
|
||||
b: &mut Brep,
|
||||
e: EdgeId,
|
||||
angle: f64,
|
||||
) -> solidcore::Result<Option<Tangency>> {
|
||||
if let Some((_, t)) = self.known.iter().find(|(f, _)| *f == e) {
|
||||
return Ok(*t);
|
||||
}
|
||||
let t = tangent_all_along(k, b, e, angle)?;
|
||||
self.known.push((e, t));
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle a chain takes: finite, strictly between 0 and a right angle.
|
||||
fn angle_ok(angle: f64) -> bool {
|
||||
angle > 0.0 && angle < std::f64::consts::FRAC_PI_2
|
||||
}
|
||||
|
||||
fn index_of<T: PartialEq + Copy>(list: &[T], x: T) -> Option<usize> {
|
||||
list.iter().position(|&y| y == x)
|
||||
}
|
||||
|
||||
/// The tangent chain of `body`'s edges at record indices `edges` (through `order`, its
|
||||
/// record's [`Order`]), as the module documentation describes, at the caller's `angle`;
|
||||
/// `passed_over` names edges a pick passes over, which never join.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for no edges, an angle not finite and strictly between 0 and a right
|
||||
/// angle, or an index past the order or naming an edge the body does not have; the kernel's
|
||||
/// refusals (a face with no NURBS form, an edge use with no pcurve, an edge too costly to
|
||||
/// decide).
|
||||
pub fn tangent_chain_ordered(
|
||||
body: &Body,
|
||||
order: &Order,
|
||||
edges: &[usize],
|
||||
passed_over: &[usize],
|
||||
angle: f64,
|
||||
) -> Result<Chain> {
|
||||
if edges.is_empty() || !angle_ok(angle) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let (copy, mapped) = body.copy_with_order(order)?;
|
||||
with_body("tangent_chain_ordered", ©, |k, b| {
|
||||
let list = mapped.edges.clone();
|
||||
let mut chain: Vec<EdgeId> = Vec::new();
|
||||
for &i in edges {
|
||||
match list.get(i) {
|
||||
Some(&e) if b.edge_list().contains(&e) => {
|
||||
if !chain.contains(&e) {
|
||||
chain.push(e);
|
||||
}
|
||||
}
|
||||
_ => return Ok(Err(Error::Argument)),
|
||||
}
|
||||
}
|
||||
let skip: Vec<EdgeId> = passed_over
|
||||
.iter()
|
||||
.filter_map(|&i| list.get(i).copied())
|
||||
.collect();
|
||||
let mut smooth = Smooth { known: Vec::new() };
|
||||
let mut undecided: Vec<EdgeId> = Vec::new();
|
||||
let mut next = 0;
|
||||
while let Some(&e) = chain.get(next) {
|
||||
next += 1;
|
||||
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
|
||||
for v in ends.into_iter().flatten() {
|
||||
for &f in &list {
|
||||
if chain.contains(&f) || skip.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let meets = [edge::start_vertex(b, f)?, edge::end_vertex(b, f)?];
|
||||
if !meets.contains(&Some(v)) {
|
||||
continue;
|
||||
}
|
||||
match smooth.of(k, b, f, angle)? {
|
||||
None | Some(Tangency::Tangent) => continue,
|
||||
Some(Tangency::Undecided { .. }) => {
|
||||
if !undecided.contains(&f) {
|
||||
undecided.push(f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Tangency::NotTangent) => {}
|
||||
}
|
||||
let turns_over = matches!(
|
||||
(convex_at(k, b, e, v)?, convex_at(k, b, f, v)?),
|
||||
(Some(a), Some(c)) if a != c
|
||||
);
|
||||
if turns_over {
|
||||
continue;
|
||||
}
|
||||
if turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
|
||||
chain.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let indices = |of: &[EdgeId]| -> Option<Vec<usize>> {
|
||||
of.iter().map(|&e| index_of(&list, e)).collect()
|
||||
};
|
||||
Ok(match (indices(&chain), indices(&undecided)) {
|
||||
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
|
||||
_ => Err(Error::Exception),
|
||||
})
|
||||
})?
|
||||
}
|
||||
|
||||
/// The faces of `body` at record indices `faces` (through `order`), then every face across a
|
||||
/// smooth edge from one of them, and so on (module documentation), at the caller's `angle`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for no faces, an angle as for [`tangent_chain_ordered`], or an index
|
||||
/// past the order or naming a face the body does not have; the kernel's refusals as for
|
||||
/// [`tangent_chain_ordered`].
|
||||
pub fn tangent_face_chain_ordered(
|
||||
body: &Body,
|
||||
order: &Order,
|
||||
faces: &[usize],
|
||||
angle: f64,
|
||||
) -> Result<Chain> {
|
||||
if faces.is_empty() || !angle_ok(angle) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let (copy, mapped) = body.copy_with_order(order)?;
|
||||
with_body("tangent_face_chain_ordered", ©, |k, b| {
|
||||
let face_list = mapped.faces.clone();
|
||||
let edge_list = mapped.edges.clone();
|
||||
let mut chain: Vec<FaceId> = Vec::new();
|
||||
for &i in faces {
|
||||
match face_list.get(i) {
|
||||
Some(&f) if b.face_list().contains(&f) => {
|
||||
if !chain.contains(&f) {
|
||||
chain.push(f);
|
||||
}
|
||||
}
|
||||
_ => return Ok(Err(Error::Argument)),
|
||||
}
|
||||
}
|
||||
let mut smooth = Smooth { known: Vec::new() };
|
||||
let mut undecided: Vec<EdgeId> = Vec::new();
|
||||
let mut next = 0;
|
||||
while let Some(&f) = chain.get(next) {
|
||||
next += 1;
|
||||
for &e in &edge_list {
|
||||
let across = solidcore::query::edge::edge_faces(b, e)?;
|
||||
if !across.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
match smooth.of(k, b, e, angle)? {
|
||||
Some(Tangency::Tangent) => {}
|
||||
Some(Tangency::Undecided { .. }) => {
|
||||
if !undecided.contains(&e) {
|
||||
undecided.push(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
None | Some(Tangency::NotTangent) => continue,
|
||||
}
|
||||
for g in across {
|
||||
if !chain.contains(&g) {
|
||||
chain.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let members: Option<Vec<usize>> = chain.iter().map(|&f| index_of(&face_list, f)).collect();
|
||||
let undecided: Option<Vec<usize>> =
|
||||
undecided.iter().map(|&e| index_of(&edge_list, e)).collect();
|
||||
Ok(match (members, undecided) {
|
||||
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
|
||||
_ => Err(Error::Exception),
|
||||
})
|
||||
})?
|
||||
}
|
||||
+71
-3
@@ -1,8 +1,12 @@
|
||||
//! The boolean family on boxes whose answers are known.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
//! The boolean family on bodies whose answers are known.
|
||||
#![allow(clippy::unwrap_used, clippy::indexing_slicing)] // test
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore_ops::boolean::{boolean, imprint, union_all};
|
||||
use solidcore_ops::{make_box, session, Body, BooleanOp, Error};
|
||||
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn all_fixes() {
|
||||
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
|
||||
@@ -76,3 +80,67 @@ fn an_imprint_cuts_the_faces_the_tool_crosses() {
|
||||
assert_eq!(r.counts().unwrap(), [6, 12, 8]);
|
||||
assert!(near(volume(&r), 1.0), "{}", volume(&r));
|
||||
}
|
||||
|
||||
/// The kernel rig's case `booleans/<case>`: its entry, and its operand bodies by name, read
|
||||
/// under the corpus's fix set.
|
||||
fn corpus_case(case: &str) -> (Value, impl Fn(&str) -> Body) {
|
||||
let corpus = format!(
|
||||
"{}/../solidcore/tests/kernel_cases",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let fixes: Vec<String> =
|
||||
serde_json::from_str(&std::fs::read_to_string(format!("{corpus}/fixes.json")).unwrap())
|
||||
.unwrap();
|
||||
session::set_fixes(fixes.iter().map(String::as_str)).unwrap();
|
||||
let entry: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(format!("{corpus}/booleans/{case}.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let gz = std::fs::File::open(format!("{corpus}/booleans/{case}.bodies.json.gz")).unwrap();
|
||||
let mut text = String::new();
|
||||
flate2::read::GzDecoder::new(gz)
|
||||
.read_to_string(&mut text)
|
||||
.unwrap();
|
||||
let records: Value = serde_json::from_str(&text).unwrap();
|
||||
let trusted = entry["bodies"].clone();
|
||||
let body = move |name: &str| {
|
||||
let record: Record = serde_json::from_value(records[name].clone()).unwrap();
|
||||
Body::from_record(&record, trusted[name]["trusted"].as_bool().unwrap())
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
(entry, body)
|
||||
}
|
||||
|
||||
/// A rod cut by two one-turn screw sweeps of one channel profile, one of each hand, from its
|
||||
/// top face: the profile's radial walls meet the rod's cylinder, so each sweep's rail there is
|
||||
/// a helix on it, and the two rails cross. The crossing is found more than once, a few 1e-12
|
||||
/// apart in the rail's parameter; the new edge along the second rail took the interval its
|
||||
/// UV curve's trim had snapped onto a knot of its own, 2.4e-12 before the 3D curve's end
|
||||
/// (`SmEdge` test 2). Cut in turn and cut by the two sweeps' union, the result is one valid
|
||||
/// solid of the case's closed form (its `expected.method`).
|
||||
#[test]
|
||||
fn a_rod_cut_by_two_counter_wound_channel_sweeps_is_valid() {
|
||||
let (entry, body) = corpus_case("13462-rod-cut-by-two-counter-wound-channel-coils");
|
||||
let want = entry["expected"]["volume_mm3"].as_f64().unwrap();
|
||||
let rel = entry["expected"]["tolerance"]["rel"].as_f64().unwrap();
|
||||
// b6 the rod, b4 and b5 the right- and left-handed sweeps, b14 the rod cut by b4 as the
|
||||
// case's run read it back from its record
|
||||
let in_turn = boolean(body("b14"), body("b5"), BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let both = union_all(vec![body("b4"), body("b5")]).unwrap();
|
||||
let at_once = boolean(body("b6"), both, BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (how, r) in [("in turn", &in_turn), ("by the union", &at_once)] {
|
||||
let report = r.check(CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "cut {how}: {}", report.text);
|
||||
assert_eq!(r.solid_count().unwrap(), 1, "cut {how}");
|
||||
let v = volume(r);
|
||||
assert!(
|
||||
(v - want).abs() <= rel * want,
|
||||
"cut {how}: volume {v}, closed form {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
//! Contact between bodies on closed forms: meets, meets against several, the boundary
|
||||
//! distance, and the refusals.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use solidcore_ops::session;
|
||||
use solidcore_ops::{make_box, make_sphere, Error};
|
||||
|
||||
fn all_fixes() {
|
||||
session::set_thread_fixes(solidcore::kernel::UpstreamFixes::NAMES.iter().copied()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_sharing_a_face_meet() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([1.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(a.meets(&b, 1e-6).unwrap());
|
||||
assert!(b.meets(&a, 1e-6).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_one_apart_do_not_meet_and_their_distance_is_one() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(!a.meets(&b, 1e-3).unwrap());
|
||||
let g = a.boundary_distance(&b, 1e-9).unwrap();
|
||||
assert!(g.lower <= 1.0 && 1.0 <= g.upper, "{g:?}");
|
||||
assert!(g.upper - g.lower <= 1e-9 * g.upper.max(1.0), "{g:?}");
|
||||
let [p, q] = g.witness.unwrap();
|
||||
assert!(
|
||||
(p[0] - 1.0).abs() <= 1e-12 && (q[0] - 2.0).abs() <= 1e-12,
|
||||
"{g:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_meets_itself_and_one_it_encloses() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [3.0, 3.0, 3.0]).unwrap();
|
||||
let inner = make_box([1.0, 1.0, 1.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
assert!(a.meets(&a, 1e-6).unwrap());
|
||||
assert!(a.meets(&inner, 1e-6).unwrap());
|
||||
assert!(inner.meets(&a, 1e-6).unwrap());
|
||||
// neither body changes
|
||||
assert_eq!(a.counts().unwrap(), [6, 12, 8]);
|
||||
assert_eq!(inner.counts().unwrap(), [6, 12, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meets_each_answers_each_with_its_own_tolerance() {
|
||||
all_fixes();
|
||||
let a = make_sphere([0.0, 0.0, 0.0], 1.0).unwrap();
|
||||
let touching = make_box([1.0, -1.0, -1.0], [1.0, 2.0, 2.0]).unwrap();
|
||||
let near = make_sphere([2.5, 0.0, 0.0], 1.0).unwrap(); // 0.5 apart
|
||||
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let got = a
|
||||
.meets_each(&[(&touching, 1e-6), (&near, 0.1), (&near, 0.6), (&far, 1.0)])
|
||||
.unwrap();
|
||||
assert_eq!(got, vec![true, false, true, false]);
|
||||
}
|
||||
|
||||
/// The refusal is the kernel's own bracket: a tolerance equal to a gap the kernel brackets
|
||||
/// to its rounding is inside the bracket, and neither answer is given.
|
||||
#[test]
|
||||
fn a_tolerance_inside_the_bracket_is_refused_with_it() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let far = make_box([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
match a.meets(&b, 1.0) {
|
||||
Err(Error::ContactUndecided {
|
||||
other: 0,
|
||||
lower,
|
||||
upper,
|
||||
}) => assert!(lower <= 1.0 && 1.0 <= upper, "[{lower}, {upper}]"),
|
||||
r => panic!("{r:?}"),
|
||||
}
|
||||
match a.meets_each(&[(&far, 1e-3), (&b, 1.0)]) {
|
||||
Err(Error::ContactUndecided { other: 1, .. }) => {}
|
||||
r => panic!("{r:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tolerance_or_width_not_finite_and_positive_is_refused() {
|
||||
all_fixes();
|
||||
let a = make_box([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let b = make_box([2.0, 0.0, 0.0], [1.0, 1.0, 1.0]).unwrap();
|
||||
let invalid = Error::Status(solidcore::error::SM_ERR_INVALID_INPUT);
|
||||
for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(a.meets(&b, bad).unwrap_err(), invalid, "{bad}");
|
||||
assert_eq!(a.boundary_distance(&b, bad).unwrap_err(), invalid, "{bad}");
|
||||
}
|
||||
}
|
||||
+90
-23
@@ -32,7 +32,9 @@
|
||||
//! - `KERNEL_CASES_TIMEOUT_S`: a case's time limit, 180 s by default; one past it is a hang, and
|
||||
//! the cases after it are not run (it holds the process kernel);
|
||||
//! - `KERNEL_CASES_REPORT`: a file to write the per-area table and the lists to;
|
||||
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing.
|
||||
//! - `KERNEL_CASES_REPORT_ONLY`: set, the test reports without failing;
|
||||
//! - `KERNEL_CASES_SMB`: a directory to write each run case's bodies to as `<case>.smb`, for
|
||||
//! its render (`solidcore-diag`): the final bodies, or the inputs of the refusal that ends it.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
@@ -231,31 +233,38 @@ fn f64s<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
(a.len() == N).then_some(out)
|
||||
}
|
||||
|
||||
/// A pick's record indices, optionally bounded by its record order's length.
|
||||
/// A pick's record indices, optionally bounded by its record order's length. Every pick's shape
|
||||
/// and range is checked first, so bad data fails wherever it sits; only then is a traced miss
|
||||
/// reported as unsupported (#2993).
|
||||
fn picked(op: &str, selection: &Value, length: Option<usize>) -> Result<Vec<usize>, Stop> {
|
||||
let bad = |what: &str| Stop::Fail(format!("{op}: parameter {what}"));
|
||||
selection["picked"]
|
||||
let picks = selection["picked"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("picked"))?
|
||||
.iter()
|
||||
.map(|pick| {
|
||||
if let Some(miss) = pick.get("miss") {
|
||||
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
|
||||
if pick.get("index").is_some() {
|
||||
return Err(bad("pick must hit or miss"));
|
||||
}
|
||||
return Err(Stop::Unsupported(format!("{op}: pick missed: {why}")));
|
||||
.ok_or_else(|| bad("picked"))?;
|
||||
let mut indices = Vec::with_capacity(picks.len());
|
||||
let mut missed = None;
|
||||
for pick in picks {
|
||||
if let Some(miss) = pick.get("miss") {
|
||||
let why = miss.as_str().ok_or_else(|| bad("miss"))?;
|
||||
if pick.get("index").is_some() {
|
||||
return Err(bad("pick must hit or miss"));
|
||||
}
|
||||
let index = pick["index"]
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad("index"))?;
|
||||
if length.is_some_and(|length| index >= length) {
|
||||
return Err(bad("pick outside record order"));
|
||||
}
|
||||
Ok(index)
|
||||
})
|
||||
.collect()
|
||||
missed.get_or_insert(why);
|
||||
continue;
|
||||
}
|
||||
let index = pick["index"]
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.ok_or_else(|| bad("index"))?;
|
||||
if length.is_some_and(|length| index >= length) {
|
||||
return Err(bad("pick outside record order"));
|
||||
}
|
||||
indices.push(index);
|
||||
}
|
||||
match missed {
|
||||
Some(why) => Err(Stop::Unsupported(format!("{op}: pick missed: {why}"))),
|
||||
None => Ok(indices),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses typed record picks without reordering them: misses are unsupported; bad data fails.
|
||||
@@ -1197,6 +1206,21 @@ fn grade_seams(result: &[Slot], expected: &Value) -> Result<(), Stop> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With `KERNEL_CASES_SMB` set, writes `bodies` there as `<case>.smb`, `<case>-1.smb`, ...
|
||||
fn write_smb(e: &Value, bodies: &[Slot]) {
|
||||
let Some(dir) = std::env::var_os("KERNEL_CASES_SMB") else {
|
||||
return;
|
||||
};
|
||||
let case = e["case"].as_str().unwrap();
|
||||
for (i, s) in bodies.iter().enumerate() {
|
||||
let name = match i {
|
||||
0 => format!("{case}.smb"),
|
||||
_ => format!("{case}-{i}.smb"),
|
||||
};
|
||||
std::fs::write(Path::new(&dir).join(name), s.body.to_smb(true).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a case's calls and grades the result against `rig` (or, for a hand-written case
|
||||
/// without one, `expected`): `Ok(deviation)` for a match.
|
||||
fn run_case(entry: &Path, e: &Value) -> Result<Option<f64>, Stop> {
|
||||
@@ -1298,7 +1322,10 @@ fn run_case_with_records(
|
||||
// A handled refusal is an attempt, not the case's outcome. Keep checking the
|
||||
// later operations and final geometry; an unmarked refusal still ends the case.
|
||||
(Err(_), Some(_)) if continued => {}
|
||||
(Err(_), Some(_)) => return Ok(None),
|
||||
(Err(_), Some(_)) => {
|
||||
write_smb(e, &ins);
|
||||
return Ok(None);
|
||||
}
|
||||
(Err(x), None) => return Err(Stop::Fail(format!("call {i} ({op}) refused: {x}"))),
|
||||
(Ok(_), Some(want)) => {
|
||||
return Err(Stop::Fail(format!(
|
||||
@@ -1321,6 +1348,7 @@ fn run_case_with_records(
|
||||
.ok_or_else(|| Stop::Fail(format!("no final body {n}")))?,
|
||||
);
|
||||
}
|
||||
write_smb(e, &result);
|
||||
let got = measure(&result).map_err(Stop::Fail)?;
|
||||
if let Some(seams) = e["expected"].get("seams") {
|
||||
grade_seams(&result, seams)?;
|
||||
@@ -1422,7 +1450,16 @@ fn seam_gaps_are_limited_independently_of_stored_tolerances() {
|
||||
|
||||
let body = solidcore_ops::make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let (mut record, _) = body.to_record().unwrap();
|
||||
// every stored tolerance inflated, not the body's alone: the import keeps each edge's and
|
||||
// vertex's own and takes the body's as the least of them (#3187, as SolidCraft's flat
|
||||
// import does), so a body tolerance alone no longer reaches the edges the grade reads
|
||||
record.tolerance = 0.01;
|
||||
for edge in &mut record.edges {
|
||||
edge.tolerance = 0.01;
|
||||
}
|
||||
for vertex in &mut record.vertices {
|
||||
vertex.tolerance = 0.01;
|
||||
}
|
||||
for face in &mut record.faces {
|
||||
match &mut face.surface {
|
||||
Surface::Plane { origin, .. } => {
|
||||
@@ -3907,6 +3944,36 @@ fn record_picks_preserve_order_and_duplicate_parameter_alignment() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Bad pick data fails wherever it sits in the list; a traced miss is unsupported only when
|
||||
/// every other pick is well formed (#2993).
|
||||
#[test]
|
||||
fn a_traced_miss_is_unsupported_only_among_well_formed_picks() {
|
||||
let list = |picked: Value| serde_json::json!({"kind": "edge", "picked": picked});
|
||||
for bad in [
|
||||
serde_json::json!([{"miss": "none"}, {"index": 999}]),
|
||||
serde_json::json!([{"index": 999}, {"miss": "none"}]),
|
||||
serde_json::json!([{"miss": "none"}, {}]),
|
||||
serde_json::json!([{"miss": "none"}, {"miss": "none", "index": 0}]),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
record_picks("blend", &list(bad.clone()), "edge", 2),
|
||||
Err(Stop::Fail(_))
|
||||
),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
record_picks(
|
||||
"blend",
|
||||
&list(serde_json::json!([{"index": 0}, {"miss": "none"}])),
|
||||
"edge",
|
||||
2
|
||||
),
|
||||
Err(Stop::Unsupported(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Captured split followed by merging its redundant faces, with the original grading.
|
||||
#[test]
|
||||
fn split_body_replays_face_plane() {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! `tangent::tangent_chain_ordered` and `tangent_face_chain_ordered` (#3141), on record order.
|
||||
//!
|
||||
//! A stadium prism: two straight walls and two half-cylinders, 4 high. Its top outline is
|
||||
//! four edges each running on tangentially into the next, so a pick of one top edge chains
|
||||
//! all four and nothing else (the walls meet each other smoothly, so the vertical edges
|
||||
//! between them are smooth, and the bottom outline turns square from the top's). Its walls
|
||||
//! meet across smooth edges, so a pick of one wall chains the four walls and not the caps. A
|
||||
//! box's edges and faces all meet square: a pick chains itself alone.
|
||||
//!
|
||||
//! Ported from SolidCraft (`crates/kernel/tests/chamfer_curved_holder.rs`,
|
||||
//! `one_chamfer_pick_follows_the_tangent_chain_at_any_part_size`, its #2672): a plate of side
|
||||
//! 140, 145 and 250, 10 thick, with a 50 x 10 stadium boss 10 tall on top. One pick on the
|
||||
//! boss's top straight edge chains the boss's whole top rim, its four edges at z = 20, at
|
||||
//! every plate size (SolidCraft's walk once stopped at the straight edge past a 200 mm part).
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)] // test helpers
|
||||
|
||||
use solidcore_ops::boolean::{boolean, BooleanOp};
|
||||
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
|
||||
use solidcore_ops::tangent::{tangent_chain_ordered, tangent_face_chain_ordered};
|
||||
use solidcore_ops::{make_box, Body};
|
||||
use solidcore_record::brep::{Brep as Record, Surface};
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
const H: f64 = 4.0;
|
||||
/// SolidCraft's smooth-edge angle, the caller's: half a degree.
|
||||
const ANGLE: f64 = 0.5 * PI / 180.0;
|
||||
|
||||
fn stadium() -> Body {
|
||||
let profile = [
|
||||
Curve::Line {
|
||||
start: [0.0, 0.0, 0.0],
|
||||
end: [20.0, 0.0, 0.0],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [20.0, 5.0, 0.0],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: -FRAC_PI_2,
|
||||
end: FRAC_PI_2,
|
||||
},
|
||||
Curve::Line {
|
||||
start: [20.0, 10.0, 0.0],
|
||||
end: [0.0, 10.0, 0.0],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [0.0, 5.0, 0.0],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: FRAC_PI_2,
|
||||
end: 1.5 * PI,
|
||||
},
|
||||
];
|
||||
extrude(
|
||||
&profile,
|
||||
[0.0, 0.0, 1.0],
|
||||
H,
|
||||
0.0,
|
||||
DraftCorner::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The record index of the edge between the vertices at `a` and `b`.
|
||||
fn edge(r: &Record, a: [f64; 3], b: [f64; 3]) -> usize {
|
||||
let near = |i: u32, p: [f64; 3]| {
|
||||
let q = r.vertices[i as usize].point;
|
||||
(0..3).all(|j| (q[j] - p[j]).abs() < 1e-9)
|
||||
};
|
||||
r.edges
|
||||
.iter()
|
||||
.position(|e| {
|
||||
let [s, t] = e.vertices;
|
||||
(near(s, a) && near(t, b)) || (near(s, b) && near(t, a))
|
||||
})
|
||||
.expect("an edge between the points")
|
||||
}
|
||||
|
||||
/// Whether record edge `i` lies in the top, both its ends at z = H.
|
||||
fn on_top(r: &Record, i: usize) -> bool {
|
||||
r.edges[i]
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|&v| (r.vertices[v as usize].point[2] - H).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// The record index of the planar face whose normal is along `n` (up to sign).
|
||||
fn plane_along(r: &Record, n: [f64; 3]) -> Vec<usize> {
|
||||
let cross = |a: [f64; 3], b: [f64; 3]| {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
};
|
||||
let along = |m: [f64; 3]| {
|
||||
let c = cross(m, n);
|
||||
c.iter().all(|x| x.abs() < 1e-9) && m.iter().any(|x| x.abs() > 1e-9)
|
||||
};
|
||||
(0..r.faces.len())
|
||||
.filter(|&i| match r.faces[i].surface {
|
||||
Surface::Plane { u_axis, v_axis, .. } => along(cross(u_axis, v_axis)),
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stadium_s_top_outline_is_one_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "{chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "{chain:?}");
|
||||
assert_eq!(chain.members[0], front);
|
||||
assert!(chain.members.iter().all(|&e| on_top(&r, e)), "{chain:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_passed_over_edge_stops_the_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
|
||||
let back = edge(&r, [20.0, 10.0, H], [0.0, 10.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[back], ANGLE).unwrap();
|
||||
assert_eq!(chain.members.len(), 3, "{chain:?}");
|
||||
assert!(!chain.members.contains(&back));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_edge_chains_alone() {
|
||||
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let front = edge(&r, [0.0, 0.0, H], [10.0, 0.0, H]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
|
||||
assert_eq!(chain.members, vec![front]);
|
||||
assert!(chain.undecided.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stadium_s_walls_are_one_face_chain() {
|
||||
let b = stadium();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let walls = plane_along(&r, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(walls.len(), 2);
|
||||
let chain = tangent_face_chain_ordered(&b, &order, &walls[..1], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "{chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "{chain:?}");
|
||||
for &f in &chain.members {
|
||||
let caps = plane_along(&r, [0.0, 0.0, 1.0]);
|
||||
assert!(!caps.contains(&f), "a cap joined: {chain:?}");
|
||||
}
|
||||
assert!(walls.iter().all(|w| chain.members.contains(w)), "{chain:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_face_chains_alone() {
|
||||
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let top = plane_along(&r, [0.0, 0.0, 1.0]);
|
||||
let chain = tangent_face_chain_ordered(&b, &order, &top[..1], ANGLE).unwrap();
|
||||
assert_eq!(chain.members, vec![top[0]]);
|
||||
}
|
||||
|
||||
/// The plate of side `plate` with the stadium boss on top.
|
||||
fn plate_with_stadium(plate: f64) -> Body {
|
||||
let base = make_box([-plate / 2.0, -plate / 2.0, 0.0], [plate, plate, 10.0]).unwrap();
|
||||
let z = 10.0;
|
||||
let profile = [
|
||||
Curve::Line {
|
||||
start: [-20.0, -5.0, z],
|
||||
end: [20.0, -5.0, z],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [20.0, 0.0, z],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: -FRAC_PI_2,
|
||||
end: FRAC_PI_2,
|
||||
},
|
||||
Curve::Line {
|
||||
start: [20.0, 5.0, z],
|
||||
end: [-20.0, 5.0, z],
|
||||
},
|
||||
Curve::Arc {
|
||||
center: [-20.0, 0.0, z],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: 5.0,
|
||||
start: FRAC_PI_2,
|
||||
end: 1.5 * PI,
|
||||
},
|
||||
];
|
||||
let boss = extrude(
|
||||
&profile,
|
||||
[0.0, 0.0, 1.0],
|
||||
10.0,
|
||||
0.0,
|
||||
DraftCorner::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
boolean(base, boss, BooleanOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pick_follows_the_boss_rim_at_any_part_size() {
|
||||
for plate in [140.0, 145.0, 250.0] {
|
||||
let b = plate_with_stadium(plate);
|
||||
let (r, order) = b.to_record().unwrap();
|
||||
let back = edge(&r, [20.0, 5.0, 20.0], [-20.0, 5.0, 20.0]);
|
||||
let chain = tangent_chain_ordered(&b, &order, &[back], &[], ANGLE).unwrap();
|
||||
assert!(chain.undecided.is_empty(), "plate {plate}: {chain:?}");
|
||||
assert_eq!(chain.members.len(), 4, "plate {plate}: {chain:?}");
|
||||
let at_top = |i: usize| {
|
||||
r.edges[i]
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|&v| (r.vertices[v as usize].point[2] - 20.0).abs() < 1e-9)
|
||||
};
|
||||
assert!(
|
||||
chain.members.iter().all(|&e| at_top(e)),
|
||||
"plate {plate}: {chain:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
//! and structural validation ([`brep`]), its history and metadata records, the native body
|
||||
//! file ([`native`]), and the plain data of vectors ([`Vec2`], [`Vec3`]), sketch planes
|
||||
//! ([`Plane`]) and planar profiles ([`Seg2`], [`Loop2`], [`Region2`]), and STEP's Part 21
|
||||
//! exchange structure as parsed from a file ([`step`]).
|
||||
//! exchange structure as parsed from a file with the units of its representation contexts and
|
||||
//! its Part 42 curves and surfaces ([`step`]).
|
||||
//!
|
||||
//! The record computes no geometric quantity: no square roots, trigonometry, dot or cross
|
||||
//! products, normalising or solves (clippy `disallowed-methods`, this crate's `clippy.toml`). It
|
||||
|
||||
+7
-1
@@ -1,3 +1,9 @@
|
||||
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]). Building
|
||||
//! STEP (ISO 10303) as data: the exchange structure a file is read into ([`p21`]), the units
|
||||
//! of its representation contexts ([`units`]), its Part 42 curves and surfaces ([`geom`])
|
||||
//! as the entity reader ([`read`]) maps them, and its product structure ([`product`]). Building
|
||||
//! bodies from it is `solidcore-ops`' job (docs/layers.md §2, "Importers are solidcore's").
|
||||
pub mod geom;
|
||||
pub mod p21;
|
||||
pub mod product;
|
||||
pub mod read;
|
||||
pub mod units;
|
||||
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
//! STEP geometry (ISO 10303-42) as plain data: the curves and surfaces a file defines, exactly
|
||||
//! as it defines them, with lengths in millimetres and angles in radians. Nothing is
|
||||
//! approximated, bounded, normalised or turned into another kind on the way in: a circle stays a
|
||||
//! circle with its placement, an unbounded cylinder stays unbounded, a B-spline keeps its own
|
||||
//! knots and form, and a direction keeps the ratios the file writes. Every curve and surface keeps
|
||||
//! STEP's own parameterisation, so a 2D curve in a surface's parameter space means the same thing
|
||||
//! here as in the file.
|
||||
//!
|
||||
//! Entities the file shares (one surface under many edges' pcurves) are shared here too, through
|
||||
//! `Arc`, so reading is linear in the file however often an entity is referenced.
|
||||
//!
|
||||
//! The record computes no geometric quantity (docs/layers.md §1, §4): a direction is not made a
|
||||
//! unit vector, a placement's reference direction is not made orthogonal to its axis (nor chosen
|
||||
//! when the file omits it), its y axis is not formed, a vector's length is not taken, and nothing
|
||||
//! is evaluated. Those are the kernel's, on its own types, when it builds from this data (#2578,
|
||||
//! slice 4). The formulas on the variants below say what the data means, in ISO 10303-42's
|
||||
//! terms; they are not computed here.
|
||||
//!
|
||||
//! Origin: SolidCraft's `crates/step/src/geom.rs` at `a5b71ca2` (MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Deviations from SolidCraft: [`Placement`] keeps `axis` and `ref_direction` as the file gives
|
||||
//! them (each optional, neither normalised; was: both unit and orthogonal, a missing reference
|
||||
//! direction chosen perpendicular to the axis), and [`Axis1::direction`] likewise. A `VECTOR` is
|
||||
//! a [`Vector`] of the written orientation, magnitude and unit factors (was: a unit direction
|
||||
//! and the vector's length), in [`Curve::Line`] and [`Surface::Extrusion`]. Removed, to come back
|
||||
//! as kernel primitives in slice 4: `Placement::y`, `Placement::at` and the vector helpers `add`,
|
||||
//! `sub`, `scale`, `dot`, `cross`, `norm`. Doc comments on every public item and field.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A point or vector: `[x, y, z]` (z = 0 in a 2D parameter space).
|
||||
pub type P3 = [f64; 3];
|
||||
|
||||
/// `AXIS2_PLACEMENT_3D`, or `AXIS2_PLACEMENT_2D` (no `axis`): an origin and the directions of
|
||||
/// its local Z and X axes, as the file writes them.
|
||||
///
|
||||
/// ISO 10303-42 makes the local axes from these: Z is `axis` (+Z when absent), X is
|
||||
/// `ref_direction` made orthogonal to Z (a chosen perpendicular when absent), Y is Z × X. That
|
||||
/// construction, with its checks (a zero or parallel reference direction), is the kernel's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// The location, in millimetres (or the parameter space's units).
|
||||
pub origin: P3,
|
||||
/// Local Z as the file gives it (not normalised); `None` when the file omits it and for an
|
||||
/// `AXIS2_PLACEMENT_2D`, which ISO 10303-42 defines as +Z.
|
||||
pub axis: Option<P3>,
|
||||
/// Local X as the file gives it (not normalised, not made orthogonal to `axis`); `None` when
|
||||
/// the file omits it.
|
||||
pub ref_direction: Option<P3>,
|
||||
}
|
||||
|
||||
/// `AXIS1_PLACEMENT`: an origin and a direction, as the file writes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Axis1 {
|
||||
/// The location, in millimetres.
|
||||
pub origin: P3,
|
||||
/// The direction as the file gives it (not normalised); `None` when the file omits it, which
|
||||
/// ISO 10303-42 defines as +Z.
|
||||
pub direction: Option<P3>,
|
||||
}
|
||||
|
||||
/// `VECTOR`: an orientation and a magnitude, as the file writes them, and the unit factors of
|
||||
/// the space they are in.
|
||||
///
|
||||
/// In millimetres and radians, component `i` of the vector is
|
||||
/// `scale[i] · magnitude · orientation[i] / |orientation|`. The record does not form it; the
|
||||
/// kernel does (slice 4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Vector {
|
||||
/// The direction ratios as written (not normalised; never all zero).
|
||||
pub orientation: P3,
|
||||
/// The magnitude as written, in file units (positive).
|
||||
pub magnitude: f64,
|
||||
/// The unit factor of each component: the length unit in model space, the parameter's unit
|
||||
/// (length, angle, or 1) in a surface's parameter space.
|
||||
pub scale: P3,
|
||||
}
|
||||
|
||||
/// A STEP LOGICAL.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Logical {
|
||||
/// `.T.`
|
||||
True,
|
||||
/// `.F.`
|
||||
False,
|
||||
/// `.U.`
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// `b_spline_curve_form`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CurveForm {
|
||||
/// `.POLYLINE_FORM.`
|
||||
PolylineForm,
|
||||
/// `.CIRCULAR_ARC.`
|
||||
CircularArc,
|
||||
/// `.ELLIPTIC_ARC.`
|
||||
EllipticArc,
|
||||
/// `.PARABOLIC_ARC.`
|
||||
ParabolicArc,
|
||||
/// `.HYPERBOLIC_ARC.`
|
||||
HyperbolicArc,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `b_spline_surface_form`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurfaceForm {
|
||||
/// `.PLANE_SURF.`
|
||||
PlaneSurf,
|
||||
/// `.CYLINDRICAL_SURF.`
|
||||
CylindricalSurf,
|
||||
/// `.CONICAL_SURF.`
|
||||
ConicalSurf,
|
||||
/// `.SPHERICAL_SURF.`
|
||||
SphericalSurf,
|
||||
/// `.TOROIDAL_SURF.`
|
||||
ToroidalSurf,
|
||||
/// `.SURF_OF_REVOLUTION.`
|
||||
SurfOfRevolution,
|
||||
/// `.RULED_SURF.`
|
||||
RuledSurf,
|
||||
/// `.GENERALISED_CONE.`
|
||||
GeneralisedCone,
|
||||
/// `.QUADRIC_SURF.`
|
||||
QuadricSurf,
|
||||
/// `.SURF_OF_LINEAR_EXTRUSION.`
|
||||
SurfOfLinearExtrusion,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `knot_type`. A B-spline given without knots (`BEZIER_CURVE`, `UNIFORM_CURVE`,
|
||||
/// `QUASI_UNIFORM_CURVE` and their surfaces) has them laid out on reading, and keeps the
|
||||
/// matching type here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KnotSpec {
|
||||
/// `.UNIFORM_KNOTS.`
|
||||
UniformKnots,
|
||||
/// `.QUASI_UNIFORM_KNOTS.`
|
||||
QuasiUniformKnots,
|
||||
/// `.PIECEWISE_BEZIER_KNOTS.`
|
||||
PiecewiseBezierKnots,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// A B-spline curve: distinct knots with their multiplicities, as STEP writes them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BSplineCurve {
|
||||
/// The degree (1 to `read::MAX_DEGREE`).
|
||||
pub degree: usize,
|
||||
/// The control points.
|
||||
pub points: Vec<P3>,
|
||||
/// Present for a rational curve: one positive weight per point.
|
||||
pub weights: Option<Vec<f64>>,
|
||||
/// The distinct knots, increasing.
|
||||
pub knots: Vec<f64>,
|
||||
/// Each knot's multiplicity (1 to degree + 1); they add up to points + degree + 1.
|
||||
pub multiplicities: Vec<usize>,
|
||||
/// The curve form.
|
||||
pub form: CurveForm,
|
||||
/// `closed_curve`.
|
||||
pub closed: Logical,
|
||||
/// `self_intersect`.
|
||||
pub self_intersect: Logical,
|
||||
/// The knot type.
|
||||
pub knot_spec: KnotSpec,
|
||||
}
|
||||
|
||||
/// A B-spline surface; `points[i][j]` runs along u with `i`, along v with `j`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BSplineSurface {
|
||||
/// The degree in u.
|
||||
pub u_degree: usize,
|
||||
/// The degree in v.
|
||||
pub v_degree: usize,
|
||||
/// The control net, rows along u, every row the same length.
|
||||
pub points: Vec<Vec<P3>>,
|
||||
/// Present for a rational surface: one positive weight per control point.
|
||||
pub weights: Option<Vec<Vec<f64>>>,
|
||||
/// The distinct u knots, increasing.
|
||||
pub u_knots: Vec<f64>,
|
||||
/// Each u knot's multiplicity.
|
||||
pub u_multiplicities: Vec<usize>,
|
||||
/// The distinct v knots, increasing.
|
||||
pub v_knots: Vec<f64>,
|
||||
/// Each v knot's multiplicity.
|
||||
pub v_multiplicities: Vec<usize>,
|
||||
/// The surface form.
|
||||
pub form: SurfaceForm,
|
||||
/// `u_closed`.
|
||||
pub u_closed: Logical,
|
||||
/// `v_closed`.
|
||||
pub v_closed: Logical,
|
||||
/// `self_intersect`.
|
||||
pub self_intersect: Logical,
|
||||
/// The knot type.
|
||||
pub knot_spec: KnotSpec,
|
||||
}
|
||||
|
||||
/// One end of a `TRIMMED_CURVE`: a point on the basis curve or its parameter (a trim lists one
|
||||
/// or both).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Trim {
|
||||
/// A `CARTESIAN_POINT`.
|
||||
Point(P3),
|
||||
/// A `PARAMETER_VALUE` (radians on a circle or an ellipse).
|
||||
Parameter(f64),
|
||||
}
|
||||
|
||||
/// `trimming_preference`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrimPreference {
|
||||
/// `.CARTESIAN.`
|
||||
Cartesian,
|
||||
/// `.PARAMETER.`
|
||||
Parameter,
|
||||
/// `.UNSPECIFIED.`, or anything else.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// `preferred_surface_curve_representation`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PreferredRep {
|
||||
/// `.CURVE_3D.`, or anything else.
|
||||
Curve3d,
|
||||
/// `.PCURVE_S1.`
|
||||
PcurveS1,
|
||||
/// `.PCURVE_S2.`
|
||||
PcurveS2,
|
||||
}
|
||||
|
||||
/// Which `SURFACE_CURVE` subtype.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurfaceCurveKind {
|
||||
/// `SURFACE_CURVE`.
|
||||
Surface,
|
||||
/// `SEAM_CURVE`.
|
||||
Seam,
|
||||
/// `INTERSECTION_CURVE`.
|
||||
Intersection,
|
||||
/// `BOUNDED_SURFACE_CURVE`.
|
||||
BoundedSurface,
|
||||
}
|
||||
|
||||
/// `PCURVE`: a 2D curve in the parameter space of `surface`. Its coordinates are the surface's
|
||||
/// own parameters (lengths in mm, angles in radians).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pcurve {
|
||||
/// The surface.
|
||||
pub surface: Arc<Surface>,
|
||||
/// A 2D curve: every point and placement has z = 0 and no placement has an axis.
|
||||
pub curve: Arc<Curve>,
|
||||
}
|
||||
|
||||
/// An item of a `SURFACE_CURVE`'s `associated_geometry`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Associated {
|
||||
/// A `PCURVE`.
|
||||
Pcurve(Pcurve),
|
||||
/// A surface.
|
||||
Surface(Arc<Surface>),
|
||||
}
|
||||
|
||||
/// A STEP curve.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Curve {
|
||||
/// `LINE`: `origin + t·v`, with `v` the [`Vector`].
|
||||
Line {
|
||||
/// `pnt`.
|
||||
origin: P3,
|
||||
/// `dir`.
|
||||
vector: Vector,
|
||||
},
|
||||
/// `CIRCLE`. Parameter: the angle from the placement's X axis.
|
||||
Circle {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// The radius (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `ELLIPSE`: `origin + a·cos t·X + b·sin t·Y`.
|
||||
Ellipse {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `a`, along X (positive).
|
||||
semi_axis_1: f64,
|
||||
/// `b`, along Y (positive).
|
||||
semi_axis_2: f64,
|
||||
},
|
||||
/// `HYPERBOLA`: `origin + a·cosh t·X + b·sinh t·Y`.
|
||||
Hyperbola {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `a` (positive).
|
||||
semi_axis: f64,
|
||||
/// `b` (positive).
|
||||
semi_imag_axis: f64,
|
||||
},
|
||||
/// `PARABOLA`: `origin + f·t²·X + 2f·t·Y`.
|
||||
Parabola {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `f` (not zero).
|
||||
focal_dist: f64,
|
||||
},
|
||||
/// `B_SPLINE_CURVE` and its subtypes.
|
||||
BSpline(Box<BSplineCurve>),
|
||||
/// `POLYLINE`: segment `i` (from 0) is the parameters `[i, i + 1]`.
|
||||
Polyline(Vec<P3>),
|
||||
/// `TRIMMED_CURVE`.
|
||||
Trimmed {
|
||||
/// `basis_curve`.
|
||||
basis: Arc<Curve>,
|
||||
/// `trim_1` (one or two items).
|
||||
trim_1: Vec<Trim>,
|
||||
/// `trim_2` (one or two items).
|
||||
trim_2: Vec<Trim>,
|
||||
/// `sense_agreement`.
|
||||
sense: bool,
|
||||
/// `master_representation`.
|
||||
preference: TrimPreference,
|
||||
},
|
||||
/// `SURFACE_CURVE`, `SEAM_CURVE`, `INTERSECTION_CURVE`, `BOUNDED_SURFACE_CURVE`.
|
||||
SurfaceCurve {
|
||||
/// Which subtype.
|
||||
kind: SurfaceCurveKind,
|
||||
/// `curve_3d`.
|
||||
curve_3d: Arc<Curve>,
|
||||
/// `associated_geometry` (at most two items).
|
||||
associated: Vec<Associated>,
|
||||
/// `master_representation`.
|
||||
master: PreferredRep,
|
||||
},
|
||||
/// `OFFSET_CURVE_3D`.
|
||||
OffsetCurve3d {
|
||||
/// `basis_curve`.
|
||||
basis: Arc<Curve>,
|
||||
/// `distance`, in millimetres.
|
||||
distance: f64,
|
||||
/// `self_intersect`.
|
||||
self_intersect: Logical,
|
||||
/// `ref_direction` as the file gives it (not normalised).
|
||||
ref_direction: P3,
|
||||
},
|
||||
}
|
||||
|
||||
/// A STEP surface.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Surface {
|
||||
/// `PLANE`: `origin + u·X + v·Y`.
|
||||
Plane {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
},
|
||||
/// `CYLINDRICAL_SURFACE`: `origin + r·(cos u·X + sin u·Y) + v·Z`.
|
||||
Cylinder {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `CONICAL_SURFACE`: `origin + (r + v·tan a)·(cos u·X + sin u·Y) + v·Z`.
|
||||
Cone {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (not negative).
|
||||
radius: f64,
|
||||
/// `a`, in radians, its size in (0, π/2).
|
||||
semi_angle: f64,
|
||||
},
|
||||
/// `SPHERICAL_SURFACE`: `origin + r·cos v·(cos u·X + sin u·Y) + r·sin v·Z`.
|
||||
Sphere {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `r` (positive).
|
||||
radius: f64,
|
||||
},
|
||||
/// `TOROIDAL_SURFACE`: `origin + (R + r·cos v)·(cos u·X + sin u·Y) + r·sin v·Z`.
|
||||
Torus {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `R` (may be negative: some writers use that for a spindle torus).
|
||||
major_radius: f64,
|
||||
/// `r` (positive).
|
||||
minor_radius: f64,
|
||||
},
|
||||
/// `DEGENERATE_TOROIDAL_SURFACE`: a torus whose minor radius reaches the axis;
|
||||
/// `select_outer` keeps the outer part.
|
||||
DegenerateTorus {
|
||||
/// The placement.
|
||||
position: Placement,
|
||||
/// `R`.
|
||||
major_radius: f64,
|
||||
/// `r` (positive).
|
||||
minor_radius: f64,
|
||||
/// `select_outer`.
|
||||
select_outer: bool,
|
||||
},
|
||||
/// `B_SPLINE_SURFACE` and its subtypes.
|
||||
BSpline(Box<BSplineSurface>),
|
||||
/// `SURFACE_OF_REVOLUTION`: the profile at `v` turned by the angle `u` about the axis.
|
||||
Revolution {
|
||||
/// `swept_curve`.
|
||||
profile: Arc<Curve>,
|
||||
/// `axis_position`.
|
||||
axis: Axis1,
|
||||
},
|
||||
/// `SURFACE_OF_LINEAR_EXTRUSION`: `profile(u) + v·w`, with `w` the [`Vector`].
|
||||
Extrusion {
|
||||
/// `swept_curve`.
|
||||
profile: Arc<Curve>,
|
||||
/// `extrusion_axis`.
|
||||
vector: Vector,
|
||||
},
|
||||
/// `OFFSET_SURFACE`.
|
||||
Offset {
|
||||
/// `basis_surface`.
|
||||
basis: Arc<Surface>,
|
||||
/// `distance`, in millimetres.
|
||||
distance: f64,
|
||||
/// `self_intersect`.
|
||||
self_intersect: Logical,
|
||||
},
|
||||
/// `RECTANGULAR_TRIMMED_SURFACE`, its bounds in the basis surface's parameter units.
|
||||
RectangularTrimmed {
|
||||
/// `basis_surface`.
|
||||
basis: Arc<Surface>,
|
||||
/// `u1`.
|
||||
u1: f64,
|
||||
/// `u2`.
|
||||
u2: f64,
|
||||
/// `v1`.
|
||||
v1: f64,
|
||||
/// `v2`.
|
||||
v2: f64,
|
||||
/// `usense`.
|
||||
u_sense: bool,
|
||||
/// `vsense`.
|
||||
v_sense: bool,
|
||||
},
|
||||
}
|
||||
+1088
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
//! The product structure, one case at a time: a part, an assembly with a shared sub-assembly,
|
||||
//! mapped items, colours, names with escapes, and what is refused. Lengths are the file's
|
||||
//! numbers times the context's unit, written as the same products, so every comparison is
|
||||
//! exact.
|
||||
//!
|
||||
//! Origin: the data cases of SolidCraft's `crates/kernel/src/step_in/tests.rs` at `a5b71ca2`
|
||||
//! (`hand_written_part_in_inches_with_colour`, `two_instance_assembly`,
|
||||
//! `assembly_cycles_are_bounded`, `exponential_assemblies_are_bounded`), checking the data the
|
||||
//! import builds from instead of the bodies it builds.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::{
|
||||
BodyKind, Colour, Definition, Frame, Instance, Item, ItemKind, Link, MAX_ASSEMBLY_DEPTH,
|
||||
MAX_VISITS, Operator, Product, ProductStructure, Representation, RepresentationMap, Side,
|
||||
StyledItem, SurfaceStyle, Transformation, read,
|
||||
};
|
||||
use crate::step::geom::Placement;
|
||||
use crate::step::p21::{self, Exchange};
|
||||
use crate::step::units::{Context, Units};
|
||||
|
||||
fn file(data: &str) -> Exchange {
|
||||
let t = format!(
|
||||
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
);
|
||||
match p21::parse(&t) {
|
||||
Ok(ex) if ex.duplicates.is_empty() => ex,
|
||||
Ok(ex) => panic!("ids defined twice: {:?}\n{t}", ex.duplicates),
|
||||
Err(e) => panic!("{e}\n{t}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn structure(data: &str) -> ProductStructure {
|
||||
read(&file(data)).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
|
||||
fn refused(data: &str) -> String {
|
||||
match read(&file(data)) {
|
||||
Ok(s) => panic!("read: {s:?}"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// The application contexts and a body: #8, #9, the solid #101 'Cube' (its shell #100 empty:
|
||||
/// the product structure does not read topology).
|
||||
const BASE: &str = "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\
|
||||
#9=APPLICATION_CONTEXT('');#100=CLOSED_SHELL('',());#101=MANIFOLD_SOLID_BREP('Cube',#100);\n";
|
||||
|
||||
/// A geometric context `#id` in the length unit `unit` (#id+2), radians (#id+3) and an
|
||||
/// uncertainty of 1e-6 in that unit (#id+1).
|
||||
fn context(id: u64, unit: &str) -> String {
|
||||
format!(
|
||||
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
|
||||
id + 1,
|
||||
id + 2,
|
||||
id + 3,
|
||||
id + 1,
|
||||
id + 2,
|
||||
id + 2,
|
||||
id + 3
|
||||
)
|
||||
}
|
||||
|
||||
const MM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))";
|
||||
const CM: &str = "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))";
|
||||
/// Millimetres per centimetre as `units` forms it (prefix times metre).
|
||||
const CM_MM: f64 = 1e-2 * 1000.0;
|
||||
|
||||
/// A product `name` and its definition `#pd`: `PRODUCT` #pd+1, `PRODUCT_CONTEXT` #pd+2, formation
|
||||
/// #pd+3, `PRODUCT_DEFINITION_SHAPE` #pd+4.
|
||||
fn product(pd: u64, name: &str) -> String {
|
||||
format!(
|
||||
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
|
||||
pd + 1,
|
||||
pd + 2,
|
||||
pd + 2,
|
||||
pd + 3,
|
||||
pd + 1,
|
||||
pd + 3,
|
||||
pd + 4
|
||||
)
|
||||
}
|
||||
|
||||
fn mm(len: f64) -> Context {
|
||||
Context {
|
||||
units: Units { len, ang: 1.0 },
|
||||
uncertainty: Some(1.0E-6 * len),
|
||||
}
|
||||
}
|
||||
|
||||
fn product_of(entity: u64, name: &str) -> Product {
|
||||
Product {
|
||||
entity,
|
||||
id: name.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn placement(origin: [f64; 3], axis: Option<[f64; 3]>, ref_direction: Option<[f64; 3]>) -> Frame {
|
||||
Frame::Placement(Placement {
|
||||
origin,
|
||||
axis,
|
||||
ref_direction,
|
||||
})
|
||||
}
|
||||
|
||||
/// A part in inches with a colour: SolidCraft's `hand_written_part_in_inches_with_colour`. The
|
||||
/// definition's shape is an empty representation linked to the one with the solid.
|
||||
#[test]
|
||||
fn a_part() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(
|
||||
20,
|
||||
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
|
||||
);
|
||||
t += &product(50, "Widget");
|
||||
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('brep',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
|
||||
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
|
||||
let inch = Context {
|
||||
units: Units {
|
||||
len: 25.4 * 1.0,
|
||||
ang: 1.0,
|
||||
},
|
||||
uncertainty: Some(1.0E-6 * (25.4 * 1.0)),
|
||||
};
|
||||
assert_eq!(
|
||||
structure(&t),
|
||||
ProductStructure {
|
||||
products: vec![product_of(51, "Widget")],
|
||||
definitions: vec![Definition {
|
||||
entity: 50,
|
||||
id: "design".into(),
|
||||
description: String::new(),
|
||||
formation: 53,
|
||||
product: 0,
|
||||
shapes: vec![1],
|
||||
}],
|
||||
representations: vec![
|
||||
Representation {
|
||||
entity: 60,
|
||||
kind: "ADVANCED_BREP_SHAPE_REPRESENTATION".into(),
|
||||
name: "brep".into(),
|
||||
items: vec![
|
||||
Item {
|
||||
entity: 101,
|
||||
name: "Cube".into(),
|
||||
kind: ItemKind::Body(BodyKind::ManifoldSolidBrep),
|
||||
},
|
||||
Item {
|
||||
entity: 65,
|
||||
name: String::new(),
|
||||
kind: ItemKind::Other("AXIS2_PLACEMENT_3D".into()),
|
||||
},
|
||||
],
|
||||
context: Some(20),
|
||||
units: inch,
|
||||
},
|
||||
Representation {
|
||||
entity: 62,
|
||||
kind: "SHAPE_REPRESENTATION".into(),
|
||||
name: String::new(),
|
||||
items: Vec::new(),
|
||||
context: Some(20),
|
||||
units: inch,
|
||||
},
|
||||
],
|
||||
maps: Vec::new(),
|
||||
links: vec![Link {
|
||||
entity: 63,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: None,
|
||||
}],
|
||||
occurrences: Vec::new(),
|
||||
roots: vec![0],
|
||||
styles: vec![StyledItem {
|
||||
entity: 70,
|
||||
name: String::new(),
|
||||
item: 101,
|
||||
overrides: None,
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Both,
|
||||
fill: Some(Colour::Rgb {
|
||||
name: String::new(),
|
||||
rgb: [1.0, 0.5, 0.0],
|
||||
}),
|
||||
rendering: None,
|
||||
transparency: None,
|
||||
}],
|
||||
curve: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A solid outside any product: its representation is still read, and nothing else.
|
||||
#[test]
|
||||
fn a_loose_solid() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, MM);
|
||||
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#101),#20);\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
(s.products.len(), s.definitions.len(), s.roots.len()),
|
||||
(0, 0, 0)
|
||||
);
|
||||
assert_eq!(s.representations.len(), 1);
|
||||
assert_eq!(s.representations[0].entity, 60);
|
||||
assert_eq!(s.representations[0].units, mm(1e-3 * 1000.0));
|
||||
}
|
||||
|
||||
/// The occurrence `#n` of `child` in `parent` placed by an `ITEM_DEFINED_TRANSFORMATION` of
|
||||
/// `item_1` (in `rep_1`) onto `item_2` (in `rep_2`): NAUO #n, its PDS #n+1, the transformation
|
||||
/// #n+2, the relationship #n+3, the CDSR #n+4.
|
||||
fn occurrence(
|
||||
n: u64,
|
||||
name: &str,
|
||||
parent: u64,
|
||||
child: u64,
|
||||
reps: (u64, u64),
|
||||
items: (u64, u64),
|
||||
) -> String {
|
||||
let mut t = String::new();
|
||||
let _ = write!(
|
||||
t,
|
||||
"#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{n}','{name}','',#{parent},#{child},$);\n\
|
||||
#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n\
|
||||
#{}=ITEM_DEFINED_TRANSFORMATION('','',#{},#{});\n\
|
||||
#{}=(REPRESENTATION_RELATIONSHIP('','',#{},#{})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n\
|
||||
#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n",
|
||||
n + 1,
|
||||
n + 2,
|
||||
items.0,
|
||||
items.1,
|
||||
n + 3,
|
||||
reps.0,
|
||||
reps.1,
|
||||
n + 2,
|
||||
n + 4,
|
||||
n + 3,
|
||||
n + 1
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
/// Top uses Sub twice and Part once; Sub uses Part once. Sub's placements are in centimetres,
|
||||
/// Top's and Part's in millimetres. Four occurrences, six instances; the transformation is the
|
||||
/// two placements as written, each in its representation's unit.
|
||||
#[test]
|
||||
fn an_assembly_with_a_shared_sub_assembly() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, CM);
|
||||
t += &context(30, MM);
|
||||
t += &product(50, "Top");
|
||||
t += &product(70, "Sub");
|
||||
t += &product(90, "Part");
|
||||
// Top (mm): its own frame #161 and the places of its three components #163, #165, #167.
|
||||
t += "#160=SHAPE_REPRESENTATION('Top',(#161,#163,#165,#167),#30);\n#162=SHAPE_DEFINITION_REPRESENTATION(#54,#160);\n";
|
||||
t += "#161=AXIS2_PLACEMENT_3D('',#1610,$,$);\n#1610=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += "#163=AXIS2_PLACEMENT_3D('',#1630,#1631,#1632);\n#1630=CARTESIAN_POINT('',(100.,0.,0.));\n#1631=DIRECTION('',(0.,0.,1.));\n#1632=DIRECTION('',(0.,2.,0.));\n";
|
||||
t += "#165=AXIS2_PLACEMENT_3D('',#1650,$,$);\n#1650=CARTESIAN_POINT('',(0.,100.,0.));\n";
|
||||
t += "#167=AXIS2_PLACEMENT_3D('',#1670,$,$);\n#1670=CARTESIAN_POINT('',(0.,0.,100.));\n";
|
||||
// Sub (cm): its frame #171 and the place of its part #173.
|
||||
t += "#170=SHAPE_REPRESENTATION('Sub',(#171,#173),#20);\n#172=SHAPE_DEFINITION_REPRESENTATION(#74,#170);\n";
|
||||
t += "#171=AXIS2_PLACEMENT_3D('',#1710,$,$);\n#1710=CARTESIAN_POINT('',(1.,2.,3.));\n";
|
||||
t += "#173=AXIS2_PLACEMENT_3D('',#1730,$,$);\n#1730=CARTESIAN_POINT('',(5.,0.,0.));\n";
|
||||
// Part (mm): the solid and its frame.
|
||||
t += "#180=ADVANCED_BREP_SHAPE_REPRESENTATION('Part',(#101,#181),#30);\n#182=SHAPE_DEFINITION_REPRESENTATION(#94,#180);\n";
|
||||
t += "#181=AXIS2_PLACEMENT_3D('',#1810,$,$);\n#1810=CARTESIAN_POINT('',(0.,0.,0.));\n";
|
||||
t += &occurrence(600, "Sub:1", 50, 70, (170, 160), (171, 163));
|
||||
t += &occurrence(610, "Sub:2", 50, 70, (170, 160), (171, 165));
|
||||
t += &occurrence(620, "Part:1", 70, 90, (180, 170), (181, 173));
|
||||
t += &occurrence(630, "Part:2", 50, 90, (180, 160), (181, 167));
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.products,
|
||||
[
|
||||
product_of(51, "Top"),
|
||||
product_of(71, "Sub"),
|
||||
product_of(91, "Part")
|
||||
]
|
||||
);
|
||||
// Representations in id order: #160 Top, #170 Sub, #180 Part.
|
||||
let shapes: Vec<_> = s
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|d| (d.entity, d.product, d.shapes.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shapes,
|
||||
[(50, 0, vec![0]), (70, 1, vec![1]), (90, 2, vec![2])]
|
||||
);
|
||||
assert_eq!(s.roots, [0]);
|
||||
let occ: Vec<_> = s
|
||||
.occurrences
|
||||
.iter()
|
||||
.map(|o| {
|
||||
(
|
||||
o.entity,
|
||||
o.id.as_str(),
|
||||
o.name.as_str(),
|
||||
o.parent,
|
||||
o.child,
|
||||
o.placements.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
occ,
|
||||
[
|
||||
(600, "600", "Sub:1", 0, 1, vec![0]),
|
||||
(610, "610", "Sub:2", 0, 1, vec![1]),
|
||||
(620, "620", "Part:1", 1, 2, vec![2]),
|
||||
(630, "630", "Part:2", 0, 2, vec![3]),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.occurrences[0].reference_designator, None);
|
||||
// Sub:1: Sub's frame (cm: 1, 2, 3 → 10, 20, 30 mm) onto Top's place for it, as written.
|
||||
assert_eq!(
|
||||
s.links[0],
|
||||
Link {
|
||||
entity: 603,
|
||||
rep_1: 1,
|
||||
rep_2: 0,
|
||||
transformation: Some(Transformation::Items {
|
||||
entity: 602,
|
||||
item_1: placement([1.0 * CM_MM, 2.0 * CM_MM, 3.0 * CM_MM], None, None),
|
||||
item_2: placement(
|
||||
[100.0, 0.0, 0.0],
|
||||
Some([0.0, 0.0, 1.0]),
|
||||
Some([0.0, 2.0, 0.0])
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Part:1: Part's frame (mm) onto Sub's place for it (cm: 5 → 50 mm).
|
||||
assert_eq!(
|
||||
s.links[2].transformation,
|
||||
Some(Transformation::Items {
|
||||
entity: 622,
|
||||
item_1: placement([0.0; 3], None, None),
|
||||
item_2: placement([5.0 * CM_MM, 0.0, 0.0], None, None),
|
||||
})
|
||||
);
|
||||
// Expanded: Top; Sub:1 with its Part:1; Sub:2 with its Part:1; Part:2.
|
||||
let inst = s.instances().unwrap();
|
||||
assert_eq!(
|
||||
inst,
|
||||
[
|
||||
Instance {
|
||||
definition: 0,
|
||||
occurrence: None,
|
||||
parent: None
|
||||
},
|
||||
Instance {
|
||||
definition: 1,
|
||||
occurrence: Some(0),
|
||||
parent: Some(0)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(2),
|
||||
parent: Some(1)
|
||||
},
|
||||
Instance {
|
||||
definition: 1,
|
||||
occurrence: Some(1),
|
||||
parent: Some(0)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(2),
|
||||
parent: Some(3)
|
||||
},
|
||||
Instance {
|
||||
definition: 2,
|
||||
occurrence: Some(3),
|
||||
parent: Some(0)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// SolidCraft's `exponential_assemblies_are_bounded`: P0 uses P1 twice, P1 uses P2 twice, … 30
|
||||
/// levels. Reading is linear (31 definitions, 60 occurrences); expanding stops at
|
||||
/// [`MAX_VISITS`]. Three levels expand to 1 + 2 + 4 + 8 instances.
|
||||
#[test]
|
||||
fn a_sub_assembly_shared_at_every_level_is_read_once() {
|
||||
let levels = |n: u64| {
|
||||
let mut t = String::from(BASE);
|
||||
for l in 0..=n {
|
||||
t += &product(1000 + l * 10, &format!("P{l}"));
|
||||
if l < n {
|
||||
for k in 0..2 {
|
||||
let _ = writeln!(
|
||||
t,
|
||||
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
|
||||
2000 + l * 10 + k,
|
||||
1000 + l * 10,
|
||||
1000 + (l + 1) * 10
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
};
|
||||
let s = structure(&levels(30));
|
||||
assert_eq!(
|
||||
(s.definitions.len(), s.occurrences.len(), s.roots.clone()),
|
||||
(31, 60, vec![0])
|
||||
);
|
||||
assert_eq!(
|
||||
s.instances(),
|
||||
Err(format!("more than {MAX_VISITS} assembly instances"))
|
||||
);
|
||||
assert_eq!(structure(&levels(3)).instances().map(|i| i.len()), Ok(15));
|
||||
}
|
||||
|
||||
/// A representation placed twice by `MAPPED_ITEM`s: once by a placement, once by a
|
||||
/// non-uniform operator. The map's origin is in the mapped representation's unit (inches), each
|
||||
/// target in the unit of the representation that lists it (millimetres).
|
||||
#[test]
|
||||
fn mapped_items() {
|
||||
let mut t = String::from(BASE);
|
||||
t += &context(20, MM);
|
||||
t += &context(
|
||||
30,
|
||||
"(CONVERSION_BASED_UNIT('INCH',#40)LENGTH_UNIT()NAMED_UNIT(*));\n#40=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#41);\n#41=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
|
||||
);
|
||||
t += &product(50, "Holder");
|
||||
t += "#60=SHAPE_REPRESENTATION('',(#110,#115),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
|
||||
t += "#110=MAPPED_ITEM('first',#111,#112);\n#111=REPRESENTATION_MAP(#113,#120);\n";
|
||||
t += "#112=AXIS2_PLACEMENT_3D('',#1120,#1121,$);\n#1120=CARTESIAN_POINT('',(5.,0.,0.));\n#1121=DIRECTION('',(0.,0.,-1.));\n";
|
||||
t += "#113=AXIS2_PLACEMENT_3D('',#1130,$,$);\n#1130=CARTESIAN_POINT('',(1.,0.,0.));\n";
|
||||
t += "#115=MAPPED_ITEM('second',#111,#116);\n";
|
||||
t += "#116=CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#1161,$,#1160,2.,$,$,3.);\n#1160=CARTESIAN_POINT('',(0.,7.,0.));\n#1161=DIRECTION('',(0.,1.,0.));\n";
|
||||
t += "#120=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#113),#30);\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.representations[0].items,
|
||||
[
|
||||
Item {
|
||||
entity: 110,
|
||||
name: "first".into(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: placement([5.0, 0.0, 0.0], Some([0.0, 0.0, -1.0]), None),
|
||||
},
|
||||
},
|
||||
Item {
|
||||
entity: 115,
|
||||
name: "second".into(),
|
||||
kind: ItemKind::Mapped {
|
||||
map: 0,
|
||||
target: Frame::Operator(Operator {
|
||||
axis1: Some([0.0, 1.0, 0.0]),
|
||||
axis2: None,
|
||||
axis3: None,
|
||||
origin: [0.0, 7.0, 0.0],
|
||||
scale: Some(2.0),
|
||||
scale2: None,
|
||||
scale3: Some(3.0),
|
||||
}),
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
s.maps,
|
||||
[RepresentationMap {
|
||||
entity: 111,
|
||||
origin: placement([1.0 * (25.4 * 1.0), 0.0, 0.0], None, None),
|
||||
representation: 1,
|
||||
}]
|
||||
);
|
||||
assert_eq!(s.representations[1].entity, 120);
|
||||
assert_eq!(
|
||||
s.representations[1].items[0].kind,
|
||||
ItemKind::Body(BodyKind::ManifoldSolidBrep)
|
||||
);
|
||||
}
|
||||
|
||||
/// Colours on a body and on a face: a fill colour, a rendering colour with a transparency, a
|
||||
/// pre-defined colour, one ISO 10303-46 does not name, a curve style's colour, and an override.
|
||||
#[test]
|
||||
fn colours_of_bodies_and_faces() {
|
||||
let mut t = String::from(BASE);
|
||||
t += "#102=ADVANCED_FACE('',(),#103,.T.);\n#103=PLANE('',$);\n";
|
||||
// The body: fill colour and the curve colour of its edges.
|
||||
t += "#70=STYLED_ITEM('body',(#71,#78),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.POSITIVE.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('steel',0.2,0.25,1.5);\n";
|
||||
t += "#78=PRESENTATION_STYLE_ASSIGNMENT((#79));\n#79=CURVE_STYLE('',#80,POSITIVE_LENGTH_MEASURE(0.1),#81);\n#80=DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous');\n#81=DRAUGHTING_PRE_DEFINED_COLOUR('black');\n";
|
||||
// The face, over the body's style: rendered red, a quarter transparent, filled 'mauve'.
|
||||
t += "#90=OVER_RIDING_STYLED_ITEM('face',(#91),#102,#70);\n#91=PRESENTATION_STYLE_ASSIGNMENT((#92));\n#92=SURFACE_STYLE_USAGE(.BOTH.,#93);\n#93=SURFACE_SIDE_STYLE('',(#94,#97));\n";
|
||||
t += "#94=SURFACE_STYLE_RENDERING_WITH_PROPERTIES(.NORMAL_SHADING.,#95,(#96));\n#95=DRAUGHTING_PRE_DEFINED_COLOUR('red');\n#96=SURFACE_STYLE_TRANSPARENT(0.25);\n";
|
||||
t += "#97=SURFACE_STYLE_FILL_AREA(#98);\n#98=FILL_AREA_STYLE('',(#99));\n#99=FILL_AREA_STYLE_COLOUR('',#89);\n#89=DRAUGHTING_PRE_DEFINED_COLOUR('mauve');\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.styles,
|
||||
[
|
||||
StyledItem {
|
||||
entity: 70,
|
||||
name: "body".into(),
|
||||
item: 101,
|
||||
overrides: None,
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Positive,
|
||||
fill: Some(Colour::Rgb {
|
||||
name: "steel".into(),
|
||||
rgb: [0.2, 0.25, 1.5],
|
||||
}),
|
||||
rendering: None,
|
||||
transparency: None,
|
||||
}],
|
||||
curve: vec![Colour::PreDefined {
|
||||
name: "black".into(),
|
||||
rgb: Some([0.0, 0.0, 0.0]),
|
||||
}],
|
||||
},
|
||||
StyledItem {
|
||||
entity: 90,
|
||||
name: "face".into(),
|
||||
item: 102,
|
||||
overrides: Some(70),
|
||||
surface: vec![SurfaceStyle {
|
||||
side: Side::Both,
|
||||
fill: Some(Colour::PreDefined {
|
||||
name: "mauve".into(),
|
||||
rgb: None,
|
||||
}),
|
||||
rendering: Some(Colour::PreDefined {
|
||||
name: "red".into(),
|
||||
rgb: Some([1.0, 0.0, 0.0]),
|
||||
}),
|
||||
transparency: Some(0.25),
|
||||
}],
|
||||
curve: Vec::new(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO 10303-46's eight pre-defined colours.
|
||||
#[test]
|
||||
fn pre_defined_colours() {
|
||||
let names = [
|
||||
"red", "green", "blue", "yellow", "magenta", "cyan", "black", "white",
|
||||
];
|
||||
let rgb: Vec<_> = names.iter().map(|n| super::pre_defined_rgb(n)).collect();
|
||||
assert_eq!(
|
||||
rgb,
|
||||
[
|
||||
Some([1.0, 0.0, 0.0]),
|
||||
Some([0.0, 1.0, 0.0]),
|
||||
Some([0.0, 0.0, 1.0]),
|
||||
Some([1.0, 1.0, 0.0]),
|
||||
Some([1.0, 0.0, 1.0]),
|
||||
Some([0.0, 1.0, 1.0]),
|
||||
Some([0.0, 0.0, 0.0]),
|
||||
Some([1.0, 1.0, 1.0]),
|
||||
]
|
||||
);
|
||||
assert_eq!(super::pre_defined_rgb("Red"), None);
|
||||
}
|
||||
|
||||
/// Names keep their escapes decoded, and are not trimmed.
|
||||
#[test]
|
||||
fn names_with_escapes() {
|
||||
let mut t = String::from(BASE);
|
||||
t += "#51=PRODUCT('P-\\X2\\00E9\\X0\\',' B\\X\\E4r ''7'' ','\\X2\\5706\\X0\\',(#52));\n#52=PRODUCT_CONTEXT('',#9,'mechanical');\n#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('d\\\\1',$,#53,#8);\n";
|
||||
t += &product(70, "Child");
|
||||
t += "#600=NEXT_ASSEMBLY_USAGE_OCCURRENCE('NAUO1','Gr\\X\\FC\\X\\DFe:1','',#50,#70,'R\\X2\\00E9\\X0\\f');\n";
|
||||
let s = structure(&t);
|
||||
assert_eq!(
|
||||
s.products[0],
|
||||
Product {
|
||||
entity: 51,
|
||||
id: "P-\u{e9}".into(),
|
||||
name: " B\u{e4}r '7' ".into(),
|
||||
description: "\u{5706}".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
s.definitions[0].id.as_str(),
|
||||
s.definitions[0].description.as_str()
|
||||
),
|
||||
("d\\1", "")
|
||||
);
|
||||
let o = &s.occurrences[0];
|
||||
assert_eq!(
|
||||
(
|
||||
o.id.as_str(),
|
||||
o.name.as_str(),
|
||||
o.reference_designator.as_deref()
|
||||
),
|
||||
("NAUO1", "Gr\u{fc}\u{df}e:1", Some("R\u{e9}f"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A chain of `n` + 1 definitions, each the only component of the one before.
|
||||
fn chain(n: u64) -> String {
|
||||
let mut t = String::from(BASE);
|
||||
for l in 0..=n {
|
||||
t += &product(1000 + l * 10, &format!("P{l}"));
|
||||
if l < n {
|
||||
let _ = writeln!(
|
||||
t,
|
||||
"#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);",
|
||||
5000 + l,
|
||||
1000 + l * 10,
|
||||
1000 + (l + 1) * 10
|
||||
);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_bounded() {
|
||||
let depth = MAX_ASSEMBLY_DEPTH as u64;
|
||||
let s = structure(&chain(depth));
|
||||
assert_eq!(s.instances().map(|i| i.len()), Ok(MAX_ASSEMBLY_DEPTH + 1));
|
||||
assert_eq!(refused(&chain(depth + 1)), "assembly nested too deeply");
|
||||
}
|
||||
|
||||
/// What is refused, with its message.
|
||||
#[test]
|
||||
fn refusals_say_why() {
|
||||
let two = || {
|
||||
let mut t = String::from(BASE);
|
||||
t += &product(50, "A");
|
||||
t += &product(70, "B");
|
||||
t
|
||||
};
|
||||
// A contains B contains A (SolidCraft's `assembly_cycles_are_bounded`).
|
||||
let cycle = two()
|
||||
+ "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
|
||||
assert_eq!(refused(&cycle), "assembly cycle");
|
||||
let itself = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#50,$);\n";
|
||||
assert_eq!(refused(&itself), "assembly cycle");
|
||||
let missing_child = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#999,$);\n";
|
||||
assert_eq!(
|
||||
refused(&missing_child),
|
||||
"occurrence #90: #999: expected a PRODUCT_DEFINITION"
|
||||
);
|
||||
let not_a_definition = two() + "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#51,$);\n";
|
||||
assert_eq!(
|
||||
refused(¬_a_definition),
|
||||
"occurrence #90: #51: expected a PRODUCT_DEFINITION"
|
||||
);
|
||||
let missing_product = String::from(BASE)
|
||||
+ "#53=PRODUCT_DEFINITION_FORMATION('','',#51);\n#50=PRODUCT_DEFINITION('design','',#53,#8);\n";
|
||||
assert_eq!(refused(&missing_product), "#51: expected a PRODUCT");
|
||||
let missing_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#999);\n";
|
||||
assert_eq!(refused(&missing_rep), "missing entity #999");
|
||||
let not_a_rep = two() + "#61=SHAPE_DEFINITION_REPRESENTATION(#54,#101);\n";
|
||||
assert_eq!(
|
||||
refused(¬_a_rep),
|
||||
"#101: expected a representation, found MANIFOLD_SOLID_BREP"
|
||||
);
|
||||
let missing_item = two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(#101,#999),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
|
||||
assert_eq!(refused(&missing_item), "missing entity #999");
|
||||
let mapped = |rest: &str| {
|
||||
two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(#110),$);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n#110=MAPPED_ITEM('',#111,#112);\n#112=AXIS2_PLACEMENT_3D('',#113,$,$);\n#113=CARTESIAN_POINT('',(0.,0.,0.));\n"
|
||||
+ rest
|
||||
};
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=REPRESENTATION_MAP(#112,#60);\n")),
|
||||
"mapped item cycle"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=SHAPE_REPRESENTATION('',(),$);\n")),
|
||||
"#110: mapping source #111 is a SHAPE_REPRESENTATION, not a REPRESENTATION_MAP"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped("#111=REPRESENTATION_MAP(#112,#999);\n")),
|
||||
"missing entity #999"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped(
|
||||
"#111=REPRESENTATION_MAP(#112,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
|
||||
)
|
||||
.replace(
|
||||
"#110=MAPPED_ITEM('',#111,#112);",
|
||||
"#110=MAPPED_ITEM('',#111,#114);#114=CARTESIAN_TRANSFORMATION_OPERATOR_3D('','',$,$,#113,0.,$);"
|
||||
)),
|
||||
"mapped item #110: operator scale: expected a number other than zero"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&mapped(
|
||||
"#111=REPRESENTATION_MAP(#113,#120);\n#120=SHAPE_REPRESENTATION('',(#101),$);\n"
|
||||
)),
|
||||
"representation map #111: #113: expected a placement, found CARTESIAN_POINT"
|
||||
);
|
||||
let styled = |rest: &str| two() + rest;
|
||||
assert_eq!(
|
||||
refused(&styled("#170=STYLED_ITEM('',(),#999);\n")),
|
||||
"styled item #170: missing entity #999"
|
||||
);
|
||||
assert_eq!(
|
||||
refused(&styled(
|
||||
"#170=STYLED_ITEM('',(#171),#101);\n#171=PRESENTATION_STYLE_ASSIGNMENT((#172));\n#172=SURFACE_STYLE_USAGE(.BOTH.,#173);\n#173=SURFACE_SIDE_STYLE('',(#174));\n#174=SURFACE_STYLE_RENDERING(.NORMAL_SHADING.,#175);\n#175=COLOUR_RGB('',1.,$,0.);\n"
|
||||
)),
|
||||
"styled item #170: green: expected a number"
|
||||
);
|
||||
let placed = two()
|
||||
+ "#60=SHAPE_REPRESENTATION('',(),$);\n#80=SHAPE_REPRESENTATION('',(),$);\n#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=PRODUCT_DEFINITION_SHAPE('','',#90);\n#93=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#92)SHAPE_REPRESENTATION_RELATIONSHIP());\n#94=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#93,#91);\n";
|
||||
assert_eq!(refused(&placed), "relationship #93: missing entity #92");
|
||||
}
|
||||
+1084
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+466
@@ -0,0 +1,466 @@
|
||||
//! The units of a representation context (ISO 10303-41 measures and units, ISO 10303-43
|
||||
//! representation contexts): `GLOBAL_UNIT_ASSIGNED_CONTEXT`'s length and plane angle units and
|
||||
//! `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`'s distance uncertainty, read from a parsed
|
||||
//! [`Exchange`]. Missing or malformed units read as millimetres and radians; nothing here fails.
|
||||
//!
|
||||
//! A unit is a factor: millimetres per file length unit, radians per file plane angle unit. An
|
||||
//! SI unit is its prefix times its base (`METRE` = 1000 mm, `RADIAN` = 1 rad); a
|
||||
//! `CONVERSION_BASED_UNIT` is the value of its measure times the factor of the measure's own unit
|
||||
//! (an inch is `LENGTH_MEASURE(25.4)` of millimetres, a degree `PLANE_ANGLE_MEASURE(0.01745…)`
|
||||
//! of radians). Only multiplications: the factor of a degree is the number the file writes, not
|
||||
//! a π computed here (the record computes no geometric quantity, docs/layers.md §4).
|
||||
//!
|
||||
//! Origin: SolidCraft's `crates/step/src/units.rs` and the `Units` type of
|
||||
//! `crates/step/src/read.rs` at `a5b71ca2` (MIT OR Apache-2.0), carried over with their
|
||||
//! behaviour unchanged.
|
||||
//!
|
||||
//! Deviations from SolidCraft: [`Units`] lives here (it was in `read.rs`, which is not carried
|
||||
//! over yet). The nesting bound of a `CONVERSION_BASED_UNIT` chain is the named
|
||||
//! `MAX_CONVERSION_DEPTH` (was the literal 4). Otherwise only formatting and doc comments on every
|
||||
//! public item.
|
||||
|
||||
use super::p21::{Exchange, Param};
|
||||
|
||||
/// Most `CONVERSION_BASED_UNIT` links followed from one unit (a unit defined from a unit defined
|
||||
/// from …); a deeper chain, or a cycle, reads as no unit.
|
||||
const MAX_CONVERSION_DEPTH: usize = 4;
|
||||
|
||||
/// The units of a representation context.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Units {
|
||||
/// Millimetres per file length unit.
|
||||
pub len: f64,
|
||||
/// Radians per file plane angle unit.
|
||||
pub ang: f64,
|
||||
}
|
||||
|
||||
impl Default for Units {
|
||||
/// Millimetres and radians.
|
||||
fn default() -> Self {
|
||||
Units { len: 1.0, ang: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a representation context says about its numbers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Context {
|
||||
/// The length and plane angle units (millimetres and radians where the context names none
|
||||
/// that can be read).
|
||||
pub units: Units,
|
||||
/// The context's distance uncertainty in millimetres (the last `UNCERTAINTY_MEASURE_WITH_UNIT`
|
||||
/// it lists), if any.
|
||||
pub uncertainty: Option<f64>,
|
||||
}
|
||||
|
||||
/// The factor of an SI prefix (`$` or an unknown prefix: 1).
|
||||
fn si_prefix(p: Option<&Param>) -> f64 {
|
||||
match p.and_then(Param::as_enum) {
|
||||
Some("EXA") => 1e18,
|
||||
Some("PETA") => 1e15,
|
||||
Some("TERA") => 1e12,
|
||||
Some("GIGA") => 1e9,
|
||||
Some("MEGA") => 1e6,
|
||||
Some("KILO") => 1e3,
|
||||
Some("HECTO") => 1e2,
|
||||
Some("DECA") => 1e1,
|
||||
Some("DECI") => 1e-1,
|
||||
Some("CENTI") => 1e-2,
|
||||
Some("MILLI") => 1e-3,
|
||||
Some("MICRO") => 1e-6,
|
||||
Some("NANO") => 1e-9,
|
||||
Some("PICO") => 1e-12,
|
||||
Some("FEMTO") => 1e-15,
|
||||
Some("ATTO") => 1e-18,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a unit measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UnitKind {
|
||||
/// A `LENGTH_UNIT`.
|
||||
Length,
|
||||
/// A `PLANE_ANGLE_UNIT`.
|
||||
Angle,
|
||||
/// Any other unit (solid angle, mass, …).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A unit's kind and size: millimetres for lengths, radians for plane angles (1 for anything
|
||||
/// else, or a unit that cannot be read). `None` when `#id` is not an entity, or is a
|
||||
/// `CONVERSION_BASED_UNIT` whose measure cannot be read or is not finite and positive.
|
||||
pub fn unit_factor(ex: &Exchange, id: u64) -> Option<(UnitKind, f64)> {
|
||||
factor(ex, id, 0)
|
||||
}
|
||||
|
||||
fn factor(ex: &Exchange, id: u64, depth: usize) -> Option<(UnitKind, f64)> {
|
||||
if depth > MAX_CONVERSION_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let e = ex.get(id)?;
|
||||
let kind = if e.has("LENGTH_UNIT") {
|
||||
UnitKind::Length
|
||||
} else if e.has("PLANE_ANGLE_UNIT") {
|
||||
UnitKind::Angle
|
||||
} else {
|
||||
UnitKind::Other
|
||||
};
|
||||
if let Some(si) = e.record("SI_UNIT") {
|
||||
let base = match si.get(1).and_then(Param::as_enum) {
|
||||
Some("METRE") => 1000.0,
|
||||
Some("RADIAN") => 1.0,
|
||||
_ => return Some((kind, 1.0)),
|
||||
};
|
||||
return Some((kind, si_prefix(si.first()) * base));
|
||||
}
|
||||
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
|
||||
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
|
||||
let mp = m.params();
|
||||
let v = mp.first()?.as_f64()?;
|
||||
let (_, f) = factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
|
||||
let x = v * f;
|
||||
return (x.is_finite() && x > 0.0).then_some((kind, x));
|
||||
}
|
||||
Some((kind, 1.0))
|
||||
}
|
||||
|
||||
/// The units and uncertainty of the representation context `#id` (the defaults when `#id` is
|
||||
/// not an entity or lists no readable unit).
|
||||
///
|
||||
/// Each unit `GLOBAL_UNIT_ASSIGNED_CONTEXT` lists that reads as a finite, positive length or
|
||||
/// plane angle factor sets that unit; a later one wins. Each `UNCERTAINTY_MEASURE_WITH_UNIT` that
|
||||
/// `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT` lists sets the uncertainty to its value times the factor
|
||||
/// of its unit (the context's length unit when that cannot be read), or clears it when the
|
||||
/// product is not finite and positive; the last one wins.
|
||||
pub fn context(ex: &Exchange, id: u64) -> Context {
|
||||
let mut c = Context::default();
|
||||
let Some(e) = ex.get(id) else { return c };
|
||||
if let Some(units) = e
|
||||
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
.and_then(<[Param]>::first)
|
||||
.and_then(Param::as_list)
|
||||
{
|
||||
for uid in units.iter().filter_map(Param::as_ref_id) {
|
||||
match unit_factor(ex, uid) {
|
||||
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0 => c.units.len = f,
|
||||
Some((UnitKind::Angle, f)) if f.is_finite() && f > 0.0 => c.units.ang = f,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(unc) = e
|
||||
.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT")
|
||||
.and_then(<[Param]>::first)
|
||||
.and_then(Param::as_list)
|
||||
{
|
||||
for uid in unc.iter().filter_map(Param::as_ref_id) {
|
||||
if let Some(m) = ex.get(uid)
|
||||
&& let Some(v) = m.params().first().and_then(Param::as_f64)
|
||||
{
|
||||
let f = m
|
||||
.params()
|
||||
.get(1)
|
||||
.and_then(Param::as_ref_id)
|
||||
.and_then(|x| unit_factor(ex, x))
|
||||
.map(|x| x.1);
|
||||
c.uncertainty =
|
||||
Some(v * f.unwrap_or(c.units.len)).filter(|t| t.is_finite() && *t > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::step::p21;
|
||||
use std::f64::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Millimetres, radians and steradians as SolidCraft's writer and its topology tests
|
||||
/// (`crates/step/src/topology_tests.rs`, `file`) write them.
|
||||
const MM: &str = "#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\
|
||||
#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
|
||||
#3=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());";
|
||||
|
||||
fn parse(data: &str) -> Exchange {
|
||||
let t = format!(
|
||||
"ISO-10303-21;\nHEADER;FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n{data}\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
);
|
||||
match p21::parse(&t) {
|
||||
Ok(ex) => ex,
|
||||
Err(e) => panic!("{e}\n{t}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context `#100` with the units `units` and the uncertainties `unc` (entity ids).
|
||||
fn ctx(data: &str, units: &str, unc: &str) -> Context {
|
||||
let c = format!(
|
||||
"#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc}))\
|
||||
GLOBAL_UNIT_ASSIGNED_CONTEXT(({units}))REPRESENTATION_CONTEXT('',''));"
|
||||
);
|
||||
context(&parse(&format!("{data}{c}")), 100)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidcraft_file_context() {
|
||||
// SolidCraft's topology test file: mm, rad, sr and an uncertainty of 1e-7 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#4=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1,'distance_accuracy_value','');"
|
||||
),
|
||||
"#1,#2,#3",
|
||||
"#4",
|
||||
);
|
||||
assert_eq!(c.units, Units { len: 1.0, ang: 1.0 });
|
||||
// 1e-7 times MILLI (1e-3) times METRE (1000 mm).
|
||||
assert_eq!(c.uncertainty, Some(1e-7 * (1e-3 * 1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn si_prefixes() {
|
||||
for (prefix, mm) in [
|
||||
(".MILLI.", 1e-3 * 1000.0),
|
||||
("$", 1000.0),
|
||||
(".MICRO.", 1e-6 * 1000.0),
|
||||
(".CENTI.", 1e-2 * 1000.0),
|
||||
(".KILO.", 1e3 * 1000.0),
|
||||
] {
|
||||
let c = ctx(
|
||||
&format!("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT({prefix},.METRE.));"),
|
||||
"#1",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, mm, "{prefix}");
|
||||
assert_eq!(c.units.ang, 1.0, "{prefix}");
|
||||
assert_eq!(c.uncertainty, None, "{prefix}");
|
||||
}
|
||||
// The products the table defines: a millimetre is exactly 1, a metre 1000.
|
||||
assert_eq!(1e-3 * 1000.0, 1.0);
|
||||
let ex = parse("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MICRO.,.METRE.));");
|
||||
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Length, 1e-6 * 1000.0)));
|
||||
// A milliradian.
|
||||
let ex = parse("#1=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));");
|
||||
assert_eq!(unit_factor(&ex, 1), Some((UnitKind::Angle, 1e-3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inches_and_degrees() {
|
||||
// An inch as 25.4 mm, a degree as π/180 rad (written as the shortest decimal of
|
||||
// PI / 180, so it reads as exactly that double).
|
||||
let data = format!(
|
||||
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(#12));\
|
||||
#12=DIMENSIONAL_EXPONENTS(1.,0.,0.,0.,0.,0.,0.);\
|
||||
#13=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.017453292519943295),#2);\
|
||||
#14=(CONVERSION_BASED_UNIT('DEGREE',#13)NAMED_UNIT(#15)PLANE_ANGLE_UNIT());\
|
||||
#15=DIMENSIONAL_EXPONENTS(0.,0.,0.,0.,0.,0.,0.);"
|
||||
);
|
||||
let c = ctx(&data, "#11,#14,#3", "");
|
||||
assert_eq!(
|
||||
c.units,
|
||||
Units {
|
||||
len: 25.4,
|
||||
ang: PI / 180.0
|
||||
}
|
||||
);
|
||||
// An inch defined from metres: 0.0254 times 1000 mm.
|
||||
let c = ctx(
|
||||
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
|
||||
#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.0254),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#11",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, 0.0254 * 1000.0);
|
||||
// A foot defined from the inch: a chain of two conversions, 12 times 25.4 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{data}#20=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(12.),#11);\
|
||||
#21=(CONVERSION_BASED_UNIT('FOOT',#20)LENGTH_UNIT()NAMED_UNIT(*));"
|
||||
),
|
||||
"#21",
|
||||
"",
|
||||
);
|
||||
assert_eq!(c.units.len, 12.0 * 25.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_broken_units_read_as_mm_and_rad() {
|
||||
let mm_rad = Units { len: 1.0, ang: 1.0 };
|
||||
// No such context, or a context without units.
|
||||
assert_eq!(context(&parse(MM), 100), Context::default());
|
||||
assert_eq!(context(&parse(MM), 1), Context::default());
|
||||
assert_eq!(Context::default().units, mm_rad);
|
||||
for (data, units) in [
|
||||
// Dangling references.
|
||||
("", "#7,#8"),
|
||||
// An SI unit with an unknown base: factor 1.
|
||||
(
|
||||
"#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.FURLONG.));",
|
||||
"#1",
|
||||
),
|
||||
// A conversion whose measure is missing, unreadable, zero, negative or infinite.
|
||||
(
|
||||
"#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT($,#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\
|
||||
#3=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(-1.),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('DEGREE',#3)PLANE_ANGLE_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
(
|
||||
"#2=(NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.)LENGTH_UNIT());\
|
||||
#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E300),#2);\
|
||||
#1=(CONVERSION_BASED_UNIT('BIG',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
// A conversion that refers to itself: the depth bound ends it.
|
||||
(
|
||||
"#3=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#1);\
|
||||
#1=(CONVERSION_BASED_UNIT('LOOP',#3)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1",
|
||||
),
|
||||
// Units that are neither lengths nor plane angles.
|
||||
(MM, "#3"),
|
||||
// An empty list.
|
||||
(MM, ""),
|
||||
] {
|
||||
let c = ctx(data, units, "");
|
||||
assert_eq!(c.units, mm_rad, "{data} / {units}");
|
||||
}
|
||||
// A unit list that is not a list.
|
||||
let ex = parse(&format!(
|
||||
"{MM}#100=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNIT_ASSIGNED_CONTEXT(#1)REPRESENTATION_CONTEXT('',''));"
|
||||
));
|
||||
assert_eq!(context(&ex, 100), Context::default());
|
||||
// A broken length unit leaves the readable angle unit in place.
|
||||
let c = ctx(
|
||||
"#2=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT(.MILLI.,.RADIAN.));\
|
||||
#1=(CONVERSION_BASED_UNIT('INCH',#9)LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
"#1,#2",
|
||||
"",
|
||||
);
|
||||
assert_eq!(
|
||||
c.units,
|
||||
Units {
|
||||
len: 1.0,
|
||||
ang: 1e-3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_depth_is_bounded() {
|
||||
// #1 is millimetres; #2..=#7 each 2 of the one before, so #(1+k) is 2^k mm and
|
||||
// MAX_CONVERSION_DEPTH links are followed from the unit asked for.
|
||||
let mut data = String::from("#1=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));");
|
||||
for k in 2..=7 {
|
||||
write!(
|
||||
data,
|
||||
"#{m}=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.),#{p});\
|
||||
#{k}=(CONVERSION_BASED_UNIT('U',#{m})LENGTH_UNIT()NAMED_UNIT(*));",
|
||||
m = 100 + k,
|
||||
p = k - 1
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let ex = parse(&data);
|
||||
assert_eq!(unit_factor(&ex, 5), Some((UnitKind::Length, 16.0)));
|
||||
assert_eq!(unit_factor(&ex, 6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_in_file_units() {
|
||||
let inch = format!(
|
||||
"{MM}#10=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#1);\
|
||||
#11=(CONVERSION_BASED_UNIT('INCH',#10)LENGTH_UNIT()NAMED_UNIT(*));"
|
||||
);
|
||||
// 1e-4 inch is 1e-4 * 25.4 mm.
|
||||
let c = ctx(
|
||||
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#11,'d','');"),
|
||||
"#11,#2",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.units.len, 25.4);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
|
||||
// A measure in metres in an inch context: its own unit, 1e-6 * 1000 mm.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{inch}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT($,.METRE.));\
|
||||
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#12,'d','');"
|
||||
),
|
||||
"#11",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-6 * 1000.0));
|
||||
// A measure whose unit cannot be read is in the context's length unit.
|
||||
let c = ctx(
|
||||
&format!("{inch}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),$,'d','');"),
|
||||
"#11",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * 25.4));
|
||||
// The last one listed wins.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.E-04),#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(2e-4 * (1e-3 * 1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertainty_zero_negative_or_infinite_is_none() {
|
||||
for v in ["0.", "-1.E-04", "1.E300"] {
|
||||
// In exametres (1e21 mm): 0, negative, and 1e300 * 1e21 mm, which is infinite.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#12=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.EXA.,.METRE.));\
|
||||
#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({v}),#12,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20",
|
||||
);
|
||||
assert_eq!(c.uncertainty, None, "{v}");
|
||||
}
|
||||
// A bad one after a good one clears it (the last one wins).
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.),#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21",
|
||||
);
|
||||
assert_eq!(c.uncertainty, None);
|
||||
// One without a readable value, or a dangling reference, is skipped.
|
||||
let c = ctx(
|
||||
&format!(
|
||||
"{MM}#20=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-04),#1,'d','');\
|
||||
#21=UNCERTAINTY_MEASURE_WITH_UNIT($,#1,'d','');"
|
||||
),
|
||||
"#1",
|
||||
"#20,#21,#99",
|
||||
);
|
||||
assert_eq!(c.uncertainty, Some(1e-4 * (1e-3 * 1000.0)));
|
||||
}
|
||||
}
|
||||
+644
-1
@@ -1,4 +1,5 @@
|
||||
//! The Part 21 parser over a STEP corpus: every file parses.
|
||||
//! The Part 21 parser over a STEP corpus: every file parses, the units of its representation
|
||||
//! contexts, and its Part 42 curves and surfaces.
|
||||
//!
|
||||
//! Ignored by default; the corpus is not in the repository. Run it with the directory of
|
||||
//! `.stp` / `.step` files (read in place, recursively) in `SOLIDCORE_STEP_CORPUS`:
|
||||
@@ -13,6 +14,31 @@
|
||||
//! caller holding the file's bytes would. The test fails when any file does not parse. The
|
||||
//! STEP files committed to the repository are parsed by `repository_files_parse`, which always
|
||||
//! runs.
|
||||
//!
|
||||
//! `corpus_units` (ignored too, same variable and command with `corpus_units` as the filter)
|
||||
//! reads every geometric representation context with assigned units in each file and prints
|
||||
//! one line per file and a table of how often each length and plane angle unit occurs, and
|
||||
//! how many contexts state an uncertainty. It fails when a file does not parse. Files with no
|
||||
//! context whose length unit can be read are listed and counted, not failed: a file written
|
||||
//! with Part 21 short names (`SUNT`, `LNGUNT`, `GMRPCN`, …) or one without geometry has none.
|
||||
//!
|
||||
//! `corpus_geometry` (ignored too, same variable, `corpus_geometry` as the filter) reads every
|
||||
//! curve and surface entity of each file with `step::read::Reader`, in the units of the file's
|
||||
//! first geometric context: curves in model space with `curve`, the curves of a parameter space
|
||||
//! through their `PCURVE` with `pcurve`, surfaces with `surface`. It prints one line per file
|
||||
//! and, per group and entity kind, how many were read and how many refused, with the reasons.
|
||||
//! It also lists the entities where a check the record no longer makes (a direction shorter than
|
||||
//! 1e-12, unit factors equal within 1e-12) would have decided otherwise. It fails when a file
|
||||
//! does not parse; refusals are counted, not failed.
|
||||
//!
|
||||
//! `corpus_product` (ignored too, same variable, `corpus_product` as the filter) reads the
|
||||
//! product structure of each file with `step::product::read` and expands its assembly tree. It
|
||||
//! prints one line per file and the files with assemblies, and checks the structure against the
|
||||
//! file's own entity counts: products, definitions, occurrences, body items (every solid the
|
||||
//! file has is an item of a representation the structure holds), relationships with a
|
||||
//! transformation and styled items must all be there; a mapped item it does not hold must be
|
||||
//! listed only by representations it does not reach (counted by the kind that lists them). It
|
||||
//! fails when a file does not parse, is refused, or a count differs.
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
@@ -21,9 +47,11 @@
|
||||
clippy::indexing_slicing
|
||||
)] // test
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use solidcore_record::step::p21;
|
||||
use solidcore_record::step::units::{self, UnitKind};
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
@@ -109,6 +137,116 @@ fn corpus_parses() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A length factor's name (millimetres per unit).
|
||||
fn length_name(f: f64) -> String {
|
||||
match f {
|
||||
1.0 => "mm".into(),
|
||||
25.4 => "inch".into(),
|
||||
1000.0 => "m".into(),
|
||||
10.0 => "cm".into(),
|
||||
304.8 => "foot".into(),
|
||||
_ => format!("{f} mm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plane angle factor's name (radians per unit). A degree is the number the file writes:
|
||||
/// `degree` when it is π/180 to 12 digits or more, else the written factor, so a file that
|
||||
/// rounds π/180 shorter (`0.0174532925`) shows in the table as what it is.
|
||||
fn angle_name(f: f64) -> String {
|
||||
if f == 1.0 {
|
||||
"rad".into()
|
||||
} else if (f - std::f64::consts::PI / 180.0).abs() <= 1e-14 {
|
||||
"degree".into()
|
||||
} else {
|
||||
format!("{f} rad")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_units() {
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
let mut unread = Vec::new();
|
||||
let mut lengths: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut angles: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let (mut contexts, mut with_uncertainty, mut files_read) = (0usize, 0usize, 0usize);
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut here = Vec::new();
|
||||
let mut readable = false;
|
||||
for (&id, e) in &ex.entities {
|
||||
if !(e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Whether the context names a length unit that reads as a factor, rather than
|
||||
// falling back to millimetres.
|
||||
readable |= e
|
||||
.record("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
.and_then(<[p21::Param]>::first)
|
||||
.and_then(p21::Param::as_list)
|
||||
.is_some_and(|l| {
|
||||
l.iter().filter_map(p21::Param::as_ref_id).any(|u| {
|
||||
matches!(units::unit_factor(&ex, u),
|
||||
Some((UnitKind::Length, f)) if f.is_finite() && f > 0.0)
|
||||
})
|
||||
});
|
||||
let c = units::context(&ex, id);
|
||||
contexts += 1;
|
||||
with_uncertainty += usize::from(c.uncertainty.is_some());
|
||||
let (l, a) = (length_name(c.units.len), angle_name(c.units.ang));
|
||||
*lengths.entry(l.clone()).or_default() += 1;
|
||||
*angles.entry(a.clone()).or_default() += 1;
|
||||
here.push(format!("#{id} {l}/{a} uncertainty {:?}", c.uncertainty));
|
||||
}
|
||||
println!("{name}: {} contexts: {}", here.len(), here.join(", "));
|
||||
if readable {
|
||||
files_read += 1;
|
||||
} else {
|
||||
println!("{name}: no context with a readable length unit");
|
||||
unread.push(name);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{files_read} of {} files have a context with a readable length unit; {contexts} contexts, {with_uncertainty} with an uncertainty",
|
||||
paths.len()
|
||||
);
|
||||
println!("length units (contexts): {lengths:?}");
|
||||
println!("plane angle units (contexts): {angles:?}");
|
||||
println!(
|
||||
"no context with a readable length unit: {} files {unread:?}",
|
||||
unread.len()
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"files that did not parse:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The STEP files in the repository parse to every instance they hold, each id once: a parser
|
||||
/// that dropped or merged instances would change the counts (the instance lines in the files).
|
||||
#[test]
|
||||
@@ -130,3 +268,508 @@ fn repository_files_parse() {
|
||||
assert!(ex.duplicates.is_empty(), "{path}: {:?}", ex.duplicates);
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve entity names (simple instances) the corpus count looks at: every one the reader maps,
|
||||
/// and the other ISO 10303-42 curves a file may hold, which it refuses.
|
||||
const CURVES: &[&str] = &[
|
||||
"B_SPLINE_CURVE",
|
||||
"B_SPLINE_CURVE_WITH_KNOTS",
|
||||
"BEZIER_CURVE",
|
||||
"BOUNDED_SURFACE_CURVE",
|
||||
"CIRCLE",
|
||||
"CLOTHOID",
|
||||
"COMPOSITE_CURVE",
|
||||
"COMPOSITE_CURVE_ON_SURFACE",
|
||||
"CURVE_REPLICA",
|
||||
"ELLIPSE",
|
||||
"HYPERBOLA",
|
||||
"INTERSECTION_CURVE",
|
||||
"LINE",
|
||||
"OFFSET_CURVE_2D",
|
||||
"OFFSET_CURVE_3D",
|
||||
"PARABOLA",
|
||||
"POLYLINE",
|
||||
"QUASI_UNIFORM_CURVE",
|
||||
"SEAM_CURVE",
|
||||
"SURFACE_CURVE",
|
||||
"TRIMMED_CURVE",
|
||||
"UNIFORM_CURVE",
|
||||
];
|
||||
|
||||
/// Surface entity names, likewise.
|
||||
const SURFACES: &[&str] = &[
|
||||
"B_SPLINE_SURFACE",
|
||||
"B_SPLINE_SURFACE_WITH_KNOTS",
|
||||
"BEZIER_SURFACE",
|
||||
"CONICAL_SURFACE",
|
||||
"CURVE_BOUNDED_SURFACE",
|
||||
"CYLINDRICAL_SURFACE",
|
||||
"DEGENERATE_TOROIDAL_SURFACE",
|
||||
"OFFSET_SURFACE",
|
||||
"ORIENTED_SURFACE",
|
||||
"PLANE",
|
||||
"QUASI_UNIFORM_SURFACE",
|
||||
"RECTANGULAR_COMPOSITE_SURFACE",
|
||||
"RECTANGULAR_TRIMMED_SURFACE",
|
||||
"SPHERICAL_SURFACE",
|
||||
"SURFACE_OF_LINEAR_EXTRUSION",
|
||||
"SURFACE_OF_REVOLUTION",
|
||||
"SURFACE_REPLICA",
|
||||
"TOROIDAL_SURFACE",
|
||||
"UNIFORM_SURFACE",
|
||||
];
|
||||
|
||||
/// Whether `e` is a curve or a surface for the count, and its kind: the entity name, or for a
|
||||
/// complex instance `(B_SPLINE_CURVE …)` with `rational` when it is.
|
||||
fn geometry_kind(e: &p21::Entity) -> Option<(bool, String)> {
|
||||
if e.is_complex() {
|
||||
let rational = if e.has("RATIONAL_B_SPLINE_CURVE") || e.has("RATIONAL_B_SPLINE_SURFACE") {
|
||||
" rational"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let is_curve =
|
||||
e.has("B_SPLINE_CURVE") || e.records.iter().any(|r| CURVES.contains(&r.name.as_str()));
|
||||
let is_surface = e.has("B_SPLINE_SURFACE")
|
||||
|| e.records
|
||||
.iter()
|
||||
.any(|r| SURFACES.contains(&r.name.as_str()));
|
||||
let names: Vec<&str> = e
|
||||
.records
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.filter(|n| CURVES.contains(n) || SURFACES.contains(n))
|
||||
.collect();
|
||||
let kind = format!("({}){rational}", names.join(" "));
|
||||
return match (is_curve, is_surface) {
|
||||
(true, false) => Some((true, kind)),
|
||||
(false, true) => Some((false, kind)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let n = e.name();
|
||||
if CURVES.contains(&n) {
|
||||
Some((true, n.to_string()))
|
||||
} else if SURFACES.contains(&n) {
|
||||
Some((false, n.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An error message with its entity ids replaced by `#n`, so equal reasons count together.
|
||||
fn reason(e: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = e.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
out.push(c);
|
||||
if c == '#' && chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
out.push('n');
|
||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The curves of a file that lie in a parameter space: the items of its
|
||||
/// `DEFINITIONAL_REPRESENTATION`s and the curves they refer to (a 2D trimmed curve's basis …).
|
||||
fn curves_2d(ex: &p21::Exchange) -> std::collections::BTreeSet<u64> {
|
||||
fn refs(p: &p21::Param, out: &mut Vec<u64>) {
|
||||
match p {
|
||||
p21::Param::Ref(r) => out.push(*r),
|
||||
p21::Param::List(l) | p21::Param::Typed(_, l) => l.iter().for_each(|x| refs(x, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut stack = Vec::new();
|
||||
for e in ex.entities.values() {
|
||||
if e.name() == "DEFINITIONAL_REPRESENTATION"
|
||||
&& let Some(items) = e.params().get(1)
|
||||
{
|
||||
refs(items, &mut stack);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(e) = ex.get(id) else { continue };
|
||||
if !matches!(geometry_kind(e), Some((true, _))) || !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for r in &e.records {
|
||||
r.params.iter().for_each(|p| refs(p, &mut stack));
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Read or refused, by reason.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
read: usize,
|
||||
refused: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn add<T>(&mut self, r: &Result<T, String>) {
|
||||
match r {
|
||||
Ok(_) => self.read += 1,
|
||||
Err(e) => *self.refused.entry(reason(e)).or_default() += 1,
|
||||
}
|
||||
}
|
||||
fn refused(&self) -> usize {
|
||||
self.refused.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_geometry() {
|
||||
use solidcore_record::step::read::Reader;
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
// (group, kind) -> tally, groups "curve", "surface", "pcurve" (by its 2D curve's kind).
|
||||
let mut kinds: BTreeMap<(&str, String), Tally> = BTreeMap::new();
|
||||
let mut mixed_units = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The units of the file's first geometric context (the topology reader, which comes
|
||||
// later, reads each representation in its own context).
|
||||
let contexts: Vec<units::Units> = ex
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
e.has("GEOMETRIC_REPRESENTATION_CONTEXT") && e.has("GLOBAL_UNIT_ASSIGNED_CONTEXT")
|
||||
})
|
||||
.map(|(&id, _)| units::context(&ex, id).units)
|
||||
.collect();
|
||||
let u = contexts.first().copied().unwrap_or_default();
|
||||
if contexts.iter().any(|c| *c != u) {
|
||||
mixed_units.push(name.clone());
|
||||
}
|
||||
// Where a removed check would have decided otherwise: SolidCraft refused a direction
|
||||
// whose length is at most 1e-12 (here: only one that is exactly zero), and took the two
|
||||
// unit factors of a conic's parameter space as equal within 1e-12 relative (here: only
|
||||
// when equal).
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() == "DIRECTION"
|
||||
&& let Some(r) = e.params().get(1).and_then(p21::Param::as_list)
|
||||
{
|
||||
let sq: f64 = r.iter().filter_map(p21::Param::as_f64).map(|x| x * x).sum();
|
||||
if sq > 0.0 && sq <= 1e-24 {
|
||||
removed.push(format!("{name}: #{id} a direction of length at most 1e-12"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if u.len != u.ang && (u.len - u.ang).abs() <= 1e-12 * u.len.abs().max(u.ang.abs()) {
|
||||
removed.push(format!(
|
||||
"{name}: length and angle factors equal within 1e-12"
|
||||
));
|
||||
}
|
||||
let flat = curves_2d(&ex);
|
||||
let mut reader = Reader::new(&ex, u);
|
||||
let mut here = (Tally::default(), Tally::default(), Tally::default());
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() == "PCURVE" {
|
||||
let kind = e
|
||||
.params()
|
||||
.get(2)
|
||||
.and_then(p21::Param::as_ref_id)
|
||||
.and_then(|r| ex.get(r))
|
||||
.and_then(|r| r.params().get(1)?.as_list()?.first()?.as_ref_id())
|
||||
.and_then(|c| ex.get(c))
|
||||
.and_then(geometry_kind)
|
||||
.map_or_else(|| "?".to_string(), |k| k.1);
|
||||
let r = reader.pcurve(id);
|
||||
here.2.add(&r);
|
||||
kinds.entry(("pcurve", kind)).or_default().add(&r);
|
||||
continue;
|
||||
}
|
||||
let Some((is_curve, kind)) = geometry_kind(e) else {
|
||||
continue;
|
||||
};
|
||||
if is_curve {
|
||||
if flat.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let r = reader.curve(id);
|
||||
here.0.add(&r);
|
||||
kinds.entry(("curve", kind)).or_default().add(&r);
|
||||
} else {
|
||||
let r = reader.surface(id);
|
||||
here.1.add(&r);
|
||||
kinds.entry(("surface", kind)).or_default().add(&r);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name}: curves {} read, {} refused; surfaces {} read, {} refused; pcurves {} read, {} refused",
|
||||
here.0.read,
|
||||
here.0.refused(),
|
||||
here.1.read,
|
||||
here.1.refused(),
|
||||
here.2.read,
|
||||
here.2.refused()
|
||||
);
|
||||
}
|
||||
println!("by kind (group, kind: read / refused, with reasons):");
|
||||
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
|
||||
for ((group, kind), t) in &kinds {
|
||||
println!(" {group} {kind}: {} / {}", t.read, t.refused());
|
||||
for (why, n) in &t.refused {
|
||||
println!(" {n} x {why}");
|
||||
}
|
||||
let g = totals.entry(group).or_default();
|
||||
g.0 += t.read;
|
||||
g.1 += t.refused();
|
||||
}
|
||||
println!("totals (read / refused): {totals:?}");
|
||||
println!(
|
||||
"files whose contexts have different units (read in the first): {} {mixed_units:?}",
|
||||
mixed_units.len()
|
||||
);
|
||||
println!(
|
||||
"entities a removed check would have decided otherwise: {} {removed:?}",
|
||||
removed.len()
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"files that did not parse:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The entity types counted by `corpus_product` against what the product structure holds.
|
||||
const BODY_KINDS: [&str; 4] = [
|
||||
"MANIFOLD_SOLID_BREP",
|
||||
"BREP_WITH_VOIDS",
|
||||
"FACETED_BREP",
|
||||
"SHELL_BASED_SURFACE_MODEL",
|
||||
];
|
||||
const OCCURRENCE_KINDS: [&str; 3] = [
|
||||
"NEXT_ASSEMBLY_USAGE_OCCURRENCE",
|
||||
"ASSEMBLY_COMPONENT_USAGE",
|
||||
"SPECIFIED_HIGHER_USAGE_OCCURRENCE",
|
||||
];
|
||||
const STYLED_KINDS: [&str; 3] = [
|
||||
"STYLED_ITEM",
|
||||
"OVER_RIDING_STYLED_ITEM",
|
||||
"CONTEXT_DEPENDENT_OVER_RIDING_STYLED_ITEM",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a STEP corpus in SOLIDCORE_STEP_CORPUS"]
|
||||
fn corpus_product() {
|
||||
use solidcore_record::step::product::{self, ItemKind};
|
||||
let Some(dir) = std::env::var_os("SOLIDCORE_STEP_CORPUS") else {
|
||||
eprintln!("SOLIDCORE_STEP_CORPUS is not set");
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&dir);
|
||||
let mut paths = Vec::new();
|
||||
files(root, &mut paths);
|
||||
assert!(!paths.is_empty(), "no STEP files under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
let mut assemblies = Vec::new();
|
||||
// Totals: name -> (held, census).
|
||||
let mut totals: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
|
||||
let mut coloured_by_kind: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut instances_total = 0usize;
|
||||
let mut unreached_mapped: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for path in &paths {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
let ex = match std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| p21::parse(&String::from_utf8_lossy(&b)).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: ERROR {e}");
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let census = |names: &[&str]| {
|
||||
ex.entities
|
||||
.values()
|
||||
.filter(|e| names.contains(&e.name()))
|
||||
.count()
|
||||
};
|
||||
let s = match product::read(&ex) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("{name}: REFUSED {e}");
|
||||
failures.push(format!("{name}: refused: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut bodies = std::collections::BTreeSet::new();
|
||||
let mut mapped = std::collections::BTreeSet::new();
|
||||
for r in &s.representations {
|
||||
for i in &r.items {
|
||||
match i.kind {
|
||||
ItemKind::Body(_) => {
|
||||
bodies.insert(i.entity);
|
||||
}
|
||||
ItemKind::Mapped { .. } => {
|
||||
mapped.insert(i.entity);
|
||||
}
|
||||
ItemKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let placed = s
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| l.transformation.is_some())
|
||||
.count();
|
||||
let coloured: Vec<_> = s
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|st| {
|
||||
!st.curve.is_empty()
|
||||
|| st
|
||||
.surface
|
||||
.iter()
|
||||
.any(|f| f.fill.is_some() || f.rendering.is_some())
|
||||
})
|
||||
.collect();
|
||||
for st in &coloured {
|
||||
let kind = ex.get(st.item).map_or("?", p21::Entity::name);
|
||||
*coloured_by_kind.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
let instances = match s.instances() {
|
||||
Ok(i) => i.len(),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: instances: {e}"));
|
||||
0
|
||||
}
|
||||
};
|
||||
instances_total += instances;
|
||||
let rows = [
|
||||
("products", s.products.len(), census(&["PRODUCT"])),
|
||||
(
|
||||
"definitions",
|
||||
s.definitions.len(),
|
||||
census(&[
|
||||
"PRODUCT_DEFINITION",
|
||||
"PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS",
|
||||
]),
|
||||
),
|
||||
(
|
||||
"occurrences",
|
||||
s.occurrences.len(),
|
||||
census(&OCCURRENCE_KINDS),
|
||||
),
|
||||
("body items", bodies.len(), census(&BODY_KINDS)),
|
||||
(
|
||||
"placed links",
|
||||
placed,
|
||||
ex.entities
|
||||
.values()
|
||||
.filter(|e| e.has("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION"))
|
||||
.count(),
|
||||
),
|
||||
("styled items", s.styles.len(), census(&STYLED_KINDS)),
|
||||
];
|
||||
// A mapped item the structure does not hold is listed only by representations it does
|
||||
// not reach (the draughting models of PMI views): checked, and counted apart.
|
||||
let reached: std::collections::BTreeSet<u64> =
|
||||
s.representations.iter().map(|r| r.entity).collect();
|
||||
let mut listers: BTreeMap<u64, Vec<(u64, &str)>> = BTreeMap::new();
|
||||
for (&id, e) in &ex.entities {
|
||||
for r in &e.records {
|
||||
for p in &r.params {
|
||||
for m in p
|
||||
.as_list()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(p21::Param::as_ref_id)
|
||||
{
|
||||
if ex.get(m).is_some_and(|m| m.name() == "MAPPED_ITEM") {
|
||||
listers.entry(m).or_default().push((id, e.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let all_mapped = census(&["MAPPED_ITEM"]);
|
||||
for (&id, e) in &ex.entities {
|
||||
if e.name() != "MAPPED_ITEM" || mapped.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let by = listers.get(&id).cloned().unwrap_or_default();
|
||||
if by.iter().any(|(l, _)| reached.contains(l)) {
|
||||
failures.push(format!(
|
||||
"{name}: mapped item #{id} of a reached representation not held"
|
||||
));
|
||||
}
|
||||
for (_, kind) in by {
|
||||
*unreached_mapped.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let t = totals.entry("mapped items").or_default();
|
||||
t.0 += mapped.len();
|
||||
t.1 += all_mapped;
|
||||
let mut line = format!("{name}: mapped items {}/{all_mapped};", mapped.len());
|
||||
for (what, held, n) in rows {
|
||||
let _ = std::fmt::Write::write_fmt(&mut line, format_args!(" {what} {held}/{n};"));
|
||||
let t = totals.entry(what).or_default();
|
||||
t.0 += held;
|
||||
t.1 += n;
|
||||
if held != n {
|
||||
failures.push(format!("{name}: {what}: {held} held, {n} in the file"));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{line} coloured items {}; roots {}; instances {instances}",
|
||||
coloured.len(),
|
||||
s.roots.len()
|
||||
);
|
||||
if !s.occurrences.is_empty() {
|
||||
assemblies.push(format!(
|
||||
"{name}: {} definitions, {} occurrences, {} roots, {instances} instances, {} mapped items",
|
||||
s.definitions.len(),
|
||||
s.occurrences.len(),
|
||||
s.roots.len(),
|
||||
mapped.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
println!("totals (held / in the files): {totals:?}");
|
||||
println!("coloured items by kind: {coloured_by_kind:?}");
|
||||
println!("mapped items not held, by the kind of entity listing them: {unreached_mapped:?}");
|
||||
println!("instances: {instances_total}");
|
||||
println!("files with assemblies: {}", assemblies.len());
|
||||
for a in &assemblies {
|
||||
println!(" {a}");
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
+14
-4
@@ -2298,16 +2298,26 @@ fn plan_face(
|
||||
}
|
||||
// Whatever the layout, no triangle leaves the surface by more than the tolerance.
|
||||
if !planar {
|
||||
if sag::refine(s, tol, reach, &fixed, &mut all, &mut triangles, MAX_REFINE)
|
||||
.map_err(|e| sag_stop(e, all.len()))?
|
||||
// The kernel's classifier is asked once per node over the three passes.
|
||||
let mut poles = sag::Poles::new(s, reach);
|
||||
if sag::refine(
|
||||
s,
|
||||
tol,
|
||||
&mut poles,
|
||||
&fixed,
|
||||
&mut all,
|
||||
&mut triangles,
|
||||
MAX_REFINE,
|
||||
)
|
||||
.map_err(|e| sag_stop(e, all.len()))?
|
||||
{
|
||||
caps.push(Cap::Refine);
|
||||
}
|
||||
sag::flip(s, tol, reach, &fixed, &all, &mut triangles)
|
||||
sag::flip(s, tol, &mut poles, &fixed, &all, &mut triangles)
|
||||
.map_err(|e| sag_stop(e, all.len()))?;
|
||||
// A face a bound limited is named with the sag its triangles reach.
|
||||
if !caps.is_empty() {
|
||||
let (sag, unmeasured) = sag::worst(s, reach, &fixed, &all, &triangles)
|
||||
let (sag, unmeasured) = sag::worst(s, &mut poles, &fixed, &all, &triangles)
|
||||
.map_err(|e| sag_stop(e, all.len()))?;
|
||||
stats.over_tolerance.push(OverTolerance {
|
||||
face: fi as usize,
|
||||
|
||||
+15
-2
@@ -379,7 +379,14 @@ fn the_flips_name_a_middle_the_surface_does_not_evaluate_at() {
|
||||
let uv = vec![[0.0, 0.0], [0.75, -0.5], [0.75, 0.5], [0.0, 1.0]];
|
||||
let fixed: Vec<Vec3> = uv.iter().map(|q| point(&s, *q)).collect();
|
||||
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
|
||||
match super::sag::flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris) {
|
||||
match super::sag::flip(
|
||||
&s,
|
||||
0.01,
|
||||
&mut super::sag::Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&uv,
|
||||
&mut tris,
|
||||
) {
|
||||
Err(super::sag::Stop::NoValue(NoValue(q))) => assert_eq!(q[0], 0.5, "{q:?}"),
|
||||
other => panic!("{other:?}, flipped to {tris:?}"),
|
||||
}
|
||||
@@ -544,7 +551,13 @@ fn a_step_inwards_the_surface_has_no_point_at_refuses_the_face() {
|
||||
fn the_worst_sag_is_measured_where_the_surface_evaluates() {
|
||||
let s = cylinder_without(0.5);
|
||||
let uv = [[0.0, 0.0], [0.75, -0.5], [0.75, 0.5]];
|
||||
let got = super::sag::worst(&s, 1e-9, &[], &uv, &[[0, 1, 2]]);
|
||||
let got = super::sag::worst(
|
||||
&s,
|
||||
&mut super::sag::Poles::new(&s, 1e-9),
|
||||
&[],
|
||||
&uv,
|
||||
&[[0, 1, 2]],
|
||||
);
|
||||
// Measured at its three sides' middles; its middle, unmeasured, is counted, so the face is
|
||||
// never taken as within its tolerance over it.
|
||||
assert!(
|
||||
|
||||
+197
-81
@@ -18,8 +18,9 @@ use super::{NoValue, Unclassified};
|
||||
|
||||
/// Split the triangles `tris` of a face (indices into `uv`; the first `fixed.len()` points sit
|
||||
/// at `fixed`, the rest on the surface at their parameters) until none of them lies farther
|
||||
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `reach`: how near
|
||||
/// a pole a point is taken as one (see [`solidcore::eval::uv::degenerate_within`]).
|
||||
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `poles`: the
|
||||
/// classifier of the face's nodes, shared by [`refine`], [`flip`] and [`worst`] (how near a pole a
|
||||
/// point is taken as one: see [`solidcore::eval::uv::degenerate_within`]).
|
||||
///
|
||||
/// A point the surface does not evaluate at (a node, or a middle the check needs) is the
|
||||
/// evaluator refusing the face: the error names it, and the caller leaves the face out whole.
|
||||
@@ -50,7 +51,7 @@ impl From<Unclassified> for Stop {
|
||||
pub(crate) fn refine(
|
||||
s: &EvalSurface,
|
||||
tol: f64,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &mut Vec<[f64; 2]>,
|
||||
tris: &mut Vec<[u32; 3]>,
|
||||
@@ -64,7 +65,6 @@ pub(crate) fn refine(
|
||||
.map(|d| kernel::record_vec3(d.0))
|
||||
.ok_or(NoValue(q))
|
||||
};
|
||||
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
|
||||
// How far the flat point `c` lies from the surface, measured along the normal at the surface
|
||||
// point of the same parameters: those parameters need not be the nearest (a straight line in
|
||||
// the parameters is no geodesic near a pole), and only the departure across the surface
|
||||
@@ -103,10 +103,7 @@ pub(crate) fn refine(
|
||||
continue;
|
||||
};
|
||||
let p = |i: u32| pos.get(i as usize).copied();
|
||||
let q = |i: u32| uv.get(i as usize).copied();
|
||||
let (Some(pa), Some(pb), Some(pc), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let (Some(pa), Some(pb), Some(pc)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
continue;
|
||||
};
|
||||
// Sides inside the face (two triangles on them), long enough to split, with their sag.
|
||||
@@ -117,7 +114,7 @@ pub(crate) fn refine(
|
||||
if sides.get(&e).is_none_or(|ts| ts.len() != 2) {
|
||||
continue;
|
||||
}
|
||||
let (Some(xa), Some(xb), Some(ua), Some(ub)) = (p(a), p(b), q(a), q(b)) else {
|
||||
let (Some(xa), Some(xb)) = (p(a), p(b)) else {
|
||||
continue;
|
||||
};
|
||||
let len = xa.dist(xb);
|
||||
@@ -127,7 +124,7 @@ pub(crate) fn refine(
|
||||
let sag = if let Some(&sag) = sag_of.get(&e) {
|
||||
sag
|
||||
} else {
|
||||
let sag = off(mean(&[ua, ub])?, (xa + xb) * 0.5)?;
|
||||
let sag = off(poles.mean(uv, &[a, b])?, (xa + xb) * 0.5)?;
|
||||
sag_of.insert(e, sag);
|
||||
sag
|
||||
};
|
||||
@@ -138,7 +135,7 @@ pub(crate) fn refine(
|
||||
// inside the face divides by four. A triangle whose middle is off splits its side inside
|
||||
// the face that sags most, while that side still contributes; what is left comes from
|
||||
// its boundary sides, which the edges' own sampling keeps within the tolerance.
|
||||
let centroid = mean(&[qa, qb, qc])?;
|
||||
let centroid = poles.mean(uv, &t)?;
|
||||
let worst = inner.iter().max_by(|x, y| x.0.total_cmp(&y.0)).copied();
|
||||
let split = match worst {
|
||||
Some((sag, _, e)) if sag > tol => e,
|
||||
@@ -174,14 +171,11 @@ pub(crate) fn refine(
|
||||
if from != ti {
|
||||
queue.push(ti);
|
||||
}
|
||||
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else {
|
||||
continue;
|
||||
};
|
||||
// A split is wanted and there is no room for its point.
|
||||
if uv.len() >= limit {
|
||||
return Ok(true);
|
||||
}
|
||||
let m = mean(&[ua, ub])?;
|
||||
let m = poles.mean(uv, &[split.0, split.1])?;
|
||||
let pm = at(m)?;
|
||||
// Proven: `plan_face` checks its nodes and the MAX_REFINE more it lets this add are at most
|
||||
// MAX_FACE_NODES, so the index fits a u32.
|
||||
@@ -194,29 +188,82 @@ pub(crate) fn refine(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The mean of parameter points, as the surface point between them: at a pole the parameter
|
||||
/// along the degenerate direction is free (the pole copy carries a neighbour's), so there the
|
||||
/// mean takes it from the other points alone.
|
||||
/// A point whose poles the kernel's classifier cannot tell is the error.
|
||||
fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclassified> {
|
||||
let mut out = [0.0; 2];
|
||||
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters `qs`.
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
for (d, slot) in out.iter_mut().enumerate() {
|
||||
let mut free: Vec<f64> = Vec::new();
|
||||
for q in qs {
|
||||
if !super::pole_at(s, *q, d, reach)? {
|
||||
free.push(q[d]);
|
||||
/// Which parameter directions the surface is collapsed along at each node of a face's
|
||||
/// triangulation, as the kernel's classifier says (within `reach`): a property of the node's
|
||||
/// parameters, so it is asked once per node however many triangles and rounds look at it.
|
||||
pub(crate) struct Poles<'a> {
|
||||
s: &'a EvalSurface,
|
||||
reach: f64,
|
||||
/// Per node index; `None` until asked.
|
||||
flags: Vec<Option<[bool; 2]>>,
|
||||
/// Calls of the kernel's classifier made, for the tests.
|
||||
#[cfg(test)]
|
||||
asked: usize,
|
||||
}
|
||||
|
||||
impl<'a> Poles<'a> {
|
||||
pub(crate) fn new(s: &'a EvalSurface, reach: f64) -> Self {
|
||||
Poles {
|
||||
s,
|
||||
reach,
|
||||
flags: Vec::new(),
|
||||
#[cfg(test)]
|
||||
asked: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether each direction is collapsed at node `i`, whose parameters are `q`.
|
||||
fn at(&mut self, i: u32, q: [f64; 2]) -> Result<[bool; 2], Unclassified> {
|
||||
let i = i as usize;
|
||||
if let Some(Some(f)) = self.flags.get(i) {
|
||||
return Ok(*f);
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.asked += 2;
|
||||
}
|
||||
let f = [
|
||||
super::pole_at(self.s, q, 0, self.reach)?,
|
||||
super::pole_at(self.s, q, 1, self.reach)?,
|
||||
];
|
||||
if self.flags.len() <= i {
|
||||
self.flags.resize(i + 1, None);
|
||||
}
|
||||
if let Some(slot) = self.flags.get_mut(i) {
|
||||
*slot = Some(f);
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The mean of nodes' parameters, as the surface point between them: at a pole the
|
||||
/// parameter along the degenerate direction is free (the pole copy carries a neighbour's),
|
||||
/// so there the mean takes it from the other nodes alone. A node whose poles the kernel's
|
||||
/// classifier cannot tell is the error.
|
||||
fn mean(&mut self, uv: &[[f64; 2]], nodes: &[u32]) -> Result<[f64; 2], Unclassified> {
|
||||
let mut qs: Vec<([f64; 2], [bool; 2])> = Vec::new();
|
||||
for &i in nodes {
|
||||
if let Some(&q) = uv.get(i as usize) {
|
||||
qs.push((q, self.at(i, q)?));
|
||||
}
|
||||
}
|
||||
let pick: Vec<f64> = if free.is_empty() {
|
||||
qs.iter().map(|q| q[d]).collect()
|
||||
} else {
|
||||
free
|
||||
};
|
||||
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
|
||||
let mut out = [0.0; 2];
|
||||
// Proven: `d` < 2 enumerates `out` and indexes the `[f64; 2]` parameters and flags.
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
for (d, slot) in out.iter_mut().enumerate() {
|
||||
let free: Vec<f64> = qs
|
||||
.iter()
|
||||
.filter(|(_, f)| !f[d])
|
||||
.map(|(q, _)| q[d])
|
||||
.collect();
|
||||
let pick: Vec<f64> = if free.is_empty() {
|
||||
qs.iter().map(|(q, _)| q[d]).collect()
|
||||
} else {
|
||||
free
|
||||
};
|
||||
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The farthest the triangles `tris` (as for [`refine`]) lie from the surface, measured as
|
||||
@@ -229,7 +276,7 @@ fn mean(s: &EvalSurface, reach: f64, qs: &[[f64; 2]]) -> Result<[f64; 2], Unclas
|
||||
/// classifier cannot tell.
|
||||
pub(crate) fn worst(
|
||||
s: &EvalSurface,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &[[f64; 2]],
|
||||
tris: &[[u32; 3]],
|
||||
@@ -245,8 +292,8 @@ pub(crate) fn worst(
|
||||
})
|
||||
.collect();
|
||||
// How far the flat point `c` lies from the surface at the mean of `qs`, as `refine`'s `off`.
|
||||
let off = |qs: &[[f64; 2]], c: Vec3| -> Result<Option<f64>, Unclassified> {
|
||||
let m = mean(s, reach, qs)?;
|
||||
let mut off = |nodes: &[u32], c: Vec3| -> Result<Option<f64>, Unclassified> {
|
||||
let m = poles.mean(uv, nodes)?;
|
||||
let Some((x, su, sv)) = s.derivs(m[0], m[1]).map(kernel::record_triple) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -259,7 +306,7 @@ pub(crate) fn worst(
|
||||
let (mut worst, mut unmeasured) = (0.0f64, 0usize);
|
||||
for (k, t) in tris.iter().enumerate() {
|
||||
let corner = |i: u32| Some((*uv.get(i as usize)?, *pos.get(i as usize)?));
|
||||
let (Some((qa, pa)), Some((qb, pb)), Some((qc, pc))) =
|
||||
let (Some((_, pa)), Some((_, pb)), Some((_, pc))) =
|
||||
(corner(t[0]), corner(t[1]), corner(t[2]))
|
||||
else {
|
||||
return Err(Stop::Corner(k));
|
||||
@@ -268,16 +315,16 @@ pub(crate) fn worst(
|
||||
// corner they need).
|
||||
let mut measured: Vec<Option<f64>> = Vec::new();
|
||||
measured.push(match (pa, pb, pc) {
|
||||
(Some(a), Some(b), Some(c)) => off(&[qa, qb, qc], (a + b + c) * (1.0 / 3.0))?,
|
||||
(Some(a), Some(b), Some(c)) => off(t, (a + b + c) * (1.0 / 3.0))?,
|
||||
_ => None,
|
||||
});
|
||||
for ((qx, px), (qy, py)) in [
|
||||
((qa, pa), (qb, pb)),
|
||||
((qb, pb), (qc, pc)),
|
||||
((qc, pc), (qa, pa)),
|
||||
for ((nx, px), (ny, py)) in [
|
||||
((t[0], pa), (t[1], pb)),
|
||||
((t[1], pb), (t[2], pc)),
|
||||
((t[2], pc), (t[0], pa)),
|
||||
] {
|
||||
measured.push(match (px, py) {
|
||||
(Some(x), Some(y)) => off(&[qx, qy], (x + y) * 0.5)?,
|
||||
(Some(x), Some(y)) => off(&[nx, ny], (x + y) * 0.5)?,
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
@@ -388,7 +435,7 @@ const MIN_FLIP_ANGLE: f64 = 1e-3;
|
||||
pub(crate) fn flip(
|
||||
s: &EvalSurface,
|
||||
tol: f64,
|
||||
reach: f64,
|
||||
poles: &mut Poles,
|
||||
fixed: &[Vec3],
|
||||
uv: &[[f64; 2]],
|
||||
tris: &mut [[u32; 3]],
|
||||
@@ -396,7 +443,6 @@ pub(crate) fn flip(
|
||||
if !(tol.is_finite() && tol > 0.0) || tris.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
let mean = |qs: &[[f64; 2]]| mean(s, reach, qs);
|
||||
let derivs = |q: [f64; 2]| {
|
||||
s.derivs(q[0], q[1])
|
||||
.map(kernel::record_triple)
|
||||
@@ -441,13 +487,11 @@ pub(crate) fn flip(
|
||||
angle(a, b, c).min(angle(b, c, a)).min(angle(c, a, b))
|
||||
};
|
||||
// How far a facet's normal turns from the surface normal at its middle (radians, 0 to pi/2).
|
||||
let deviation = |t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let deviation = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
let m = mean(&[qa, qb, qc])?;
|
||||
let m = poles.mean(uv, &t)?;
|
||||
let facet = (b - a).cross(c - a).normalized();
|
||||
let (_, su, sv) = derivs(m)?;
|
||||
Ok(match (facet, su.cross(sv).normalized()) {
|
||||
@@ -458,20 +502,16 @@ pub(crate) fn flip(
|
||||
};
|
||||
// Whether the facet's normal lies on the side of the surface normal that its (u, v)
|
||||
// orientation `sign` says.
|
||||
let upright = |t: [u32; 3], sign: f64| -> Result<bool, Stop> {
|
||||
let (Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) =
|
||||
(p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2]))
|
||||
else {
|
||||
let upright = |poles: &mut Poles, t: [u32; 3], sign: f64| -> Result<bool, Stop> {
|
||||
let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let (_, su, sv) = derivs(mean(&[qa, qb, qc])?)?;
|
||||
let (_, su, sv) = derivs(poles.mean(uv, &t)?)?;
|
||||
Ok((b - a).cross(c - a).dot(su.cross(sv)) * sign > 0.0)
|
||||
};
|
||||
let centroid_off = |t: [u32; 3]| -> Result<f64, Stop> {
|
||||
match (p(t[0]), p(t[1]), p(t[2]), q(t[0]), q(t[1]), q(t[2])) {
|
||||
(Some(a), Some(b), Some(c), Some(qa), Some(qb), Some(qc)) => {
|
||||
Ok(off(mean(&[qa, qb, qc])?, (a + b + c) * (1.0 / 3.0))?)
|
||||
}
|
||||
let centroid_off = |poles: &mut Poles, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
match (p(t[0]), p(t[1]), p(t[2])) {
|
||||
(Some(a), Some(b), Some(c)) => Ok(off(poles.mean(uv, &t)?, (a + b + c) * (1.0 / 3.0))?),
|
||||
_ => Ok(f64::INFINITY),
|
||||
}
|
||||
};
|
||||
@@ -523,20 +563,20 @@ pub(crate) fn flip(
|
||||
// The pair the other way must lie closer along the surface (its facets turn less from
|
||||
// the surface normal), by more than a degree: a pair within a degree of the surface is
|
||||
// left as the layout made it.
|
||||
let mut cached = |ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
let mut cached = |poles: &mut Poles, ti: u32, t: [u32; 3]| -> Result<f64, Stop> {
|
||||
if let Some(d) = dev_of.get(ti as usize).copied().flatten() {
|
||||
Ok(d)
|
||||
} else {
|
||||
let d = deviation(t)?;
|
||||
let d = deviation(poles, t)?;
|
||||
if let Some(slot) = dev_of.get_mut(ti as usize) {
|
||||
*slot = Some(d);
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
};
|
||||
let worse = cached(t1, ta)?.max(cached(t2, tb)?);
|
||||
let worse = cached(poles, t1, ta)?.max(cached(poles, t2, tb)?);
|
||||
let degree = 1f64.to_radians();
|
||||
if worse < degree || deviation(n1)?.max(deviation(n2)?) >= worse - degree {
|
||||
if worse < degree || deviation(poles, n1)?.max(deviation(poles, n2)?) >= worse - degree {
|
||||
continue;
|
||||
}
|
||||
// ... and have area in 3D (three corners on one line of a cone have none, however they
|
||||
@@ -544,21 +584,19 @@ pub(crate) fn flip(
|
||||
if min_angle(n1).min(min_angle(n2)) < MIN_FLIP_ANGLE {
|
||||
continue;
|
||||
}
|
||||
if !(upright(n1, sign)? && upright(n2, sign)?) {
|
||||
if !(upright(poles, n1, sign)? && upright(poles, n2, sign)?) {
|
||||
continue;
|
||||
}
|
||||
let (Some(pa), Some(pb), Some(pc), Some(pd), Some(qa), Some(qb), Some(qc), Some(qd)) =
|
||||
(p(a), p(b), p(c), p(d), q(a), q(b), q(c), q(d))
|
||||
else {
|
||||
let (Some(pa), Some(pb), Some(pc), Some(pd)) = (p(a), p(b), p(c), p(d)) else {
|
||||
continue;
|
||||
};
|
||||
let allowed = tol
|
||||
.max(off(mean(&[qa, qb])?, (pa + pb) * 0.5)?)
|
||||
.max(centroid_off(ta)?)
|
||||
.max(centroid_off(tb)?);
|
||||
if off(mean(&[qc, qd])?, (pc + pd) * 0.5)? > allowed
|
||||
|| centroid_off(n1)? > allowed
|
||||
|| centroid_off(n2)? > allowed
|
||||
.max(off(poles.mean(uv, &[a, b])?, (pa + pb) * 0.5)?)
|
||||
.max(centroid_off(poles, ta)?)
|
||||
.max(centroid_off(poles, tb)?);
|
||||
if off(poles.mean(uv, &[c, d])?, (pc + pd) * 0.5)? > allowed
|
||||
|| centroid_off(poles, n1)? > allowed
|
||||
|| centroid_off(poles, n2)? > allowed
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -607,7 +645,15 @@ mod tests {
|
||||
assert_eq!(fixed.len(), 4);
|
||||
let mut tris = vec![[0, 1, 2], [0, 2, 3]];
|
||||
assert_eq!(
|
||||
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
|
||||
refine(
|
||||
&s,
|
||||
0.01,
|
||||
&mut Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
1000
|
||||
),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3]]);
|
||||
@@ -628,11 +674,19 @@ mod tests {
|
||||
assert_eq!(fixed.len(), 4);
|
||||
let mut tris = vec![[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
|
||||
assert_eq!(
|
||||
refine(&s, 0.01, 1e-9, &fixed, &mut uv, &mut tris, 1000),
|
||||
refine(
|
||||
&s,
|
||||
0.01,
|
||||
&mut Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
1000
|
||||
),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
assert_eq!(
|
||||
flip(&s, 0.01, 1e-9, &fixed, &uv, &mut tris),
|
||||
flip(&s, 0.01, &mut Poles::new(&s, 1e-9), &fixed, &uv, &mut tris),
|
||||
Err(Stop::NoValue(NoValue([0.5, 0.5])))
|
||||
);
|
||||
}
|
||||
@@ -661,7 +715,7 @@ mod tests {
|
||||
};
|
||||
let uv = [[0.5, -0.1], [0.5, 0.1], [0.6, 0.0]];
|
||||
let side = 3.0 * (1.0 - 0.1f64.cos());
|
||||
let got = worst(&s, 0.0, &[], &uv, &[[0, 1, 2]]);
|
||||
let got = worst(&s, &mut Poles::new(&s, 0.0), &[], &uv, &[[0, 1, 2]]);
|
||||
assert!(
|
||||
got.as_ref()
|
||||
.is_ok_and(|(w, unmeasured)| (w - side).abs() < 1e-9 * side && *unmeasured == 0),
|
||||
@@ -669,8 +723,70 @@ mod tests {
|
||||
);
|
||||
// A corner past the nodes is the triangle's index.
|
||||
assert_eq!(
|
||||
worst(&s, 0.0, &[], &uv, &[[0, 1, 2], [0, 1, 3]]),
|
||||
worst(
|
||||
&s,
|
||||
&mut Poles::new(&s, 0.0),
|
||||
&[],
|
||||
&uv,
|
||||
&[[0, 1, 2], [0, 1, 3]]
|
||||
),
|
||||
Err(Stop::Corner(1))
|
||||
);
|
||||
}
|
||||
|
||||
/// Refining, flipping and measuring a coarse patch of a cylinder (every sag over the
|
||||
/// tolerance, so the nodes are looked at by many triangles, sides and rounds) asks the
|
||||
/// kernel's classifier once per node and direction, not once per look.
|
||||
#[test]
|
||||
fn the_classifier_is_asked_once_per_node() {
|
||||
use solidcore::eval::EvalCurve;
|
||||
let s = EvalSurface::Revolved {
|
||||
profile: EvalCurve::new(
|
||||
1,
|
||||
vec![0.0, 0.0, 1.0, 1.0],
|
||||
vec![[3.0, 0.0, 0.0, 1.0], [3.0, 0.0, 4.0, 1.0]],
|
||||
),
|
||||
origin: solidcore::Vec3::ZERO,
|
||||
axis: solidcore::Vec3::Z,
|
||||
transform: [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
],
|
||||
swap: false,
|
||||
};
|
||||
let n = 6u32;
|
||||
let mut uv: Vec<[f64; 2]> = Vec::new();
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
uv.push([
|
||||
f64::from(i) / f64::from(n),
|
||||
3.0 * f64::from(j) / f64::from(n),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let mut tris: Vec<[u32; 3]> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let a = i * (n + 1) + j;
|
||||
let (b, c, d) = (a + 1, a + n + 1, a + n + 2);
|
||||
tris.push([a, c, d]);
|
||||
tris.push([a, d, b]);
|
||||
}
|
||||
}
|
||||
let mut poles = Poles::new(&s, 1e-9);
|
||||
let start = uv.len();
|
||||
// Refinement is capped (it may or may not run out of room): the count holds either way.
|
||||
refine(&s, 5e-2, &mut poles, &[], &mut uv, &mut tris, 300).unwrap();
|
||||
flip(&s, 5e-2, &mut poles, &[], &uv, &mut tris).unwrap();
|
||||
worst(&s, &mut poles, &[], &uv, &tris).unwrap();
|
||||
assert!(uv.len() > 2 * start, "{} nodes: not refined", uv.len());
|
||||
assert!(poles.asked > 0);
|
||||
assert!(
|
||||
poles.asked <= 2 * uv.len(),
|
||||
"{} calls for {} nodes",
|
||||
poles.asked,
|
||||
uv.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -61,12 +61,28 @@ pub(crate) fn crossing(
|
||||
if n < 3 {
|
||||
continue;
|
||||
}
|
||||
// Each node is on a pole or not whichever two chords it ends: asked once.
|
||||
let mut on_pole: Vec<Option<bool>> = vec![None; n];
|
||||
let mut node_pole = |k: usize, q: [f64; 2]| -> Result<bool, super::Unclassified> {
|
||||
if let Some(p) = on_pole.get(k).copied().flatten() {
|
||||
return Ok(p);
|
||||
}
|
||||
let p = pole(q)?;
|
||||
if let Some(slot) = on_pole.get_mut(k) {
|
||||
*slot = Some(p);
|
||||
}
|
||||
Ok(p)
|
||||
};
|
||||
for k in 0..n {
|
||||
// Proven: n = l.uv.len() >= 3, checked above
|
||||
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get((k + 1) % n)) else {
|
||||
let next = (k + 1) % n;
|
||||
let (Some(&a), Some(&b)) = (l.uv.get(k), l.uv.get(next)) else {
|
||||
continue;
|
||||
};
|
||||
if !(a.iter().chain(&b).all(|x| x.is_finite())) || pole(a)? || pole(b)? {
|
||||
if !(a.iter().chain(&b).all(|x| x.is_finite()))
|
||||
|| node_pole(k, a)?
|
||||
|| node_pole(next, b)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The loop's closing chord of a loop that winds round the surface ends at the copy of
|
||||
|
||||
+27
-2
@@ -1345,7 +1345,16 @@ fn sag_refinement_across_an_inflection_ends_without_slivers() {
|
||||
let fixed: Vec<Vec3> = Vec::new();
|
||||
let tol = 0.5;
|
||||
let start = uv.len();
|
||||
sag::refine(&s, tol, 1e-9, &fixed, &mut uv, &mut tris, 20_000).unwrap();
|
||||
sag::refine(
|
||||
&s,
|
||||
tol,
|
||||
&mut sag::Poles::new(&s, 1e-9),
|
||||
&fixed,
|
||||
&mut uv,
|
||||
&mut tris,
|
||||
20_000,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
uv.len() - start < 1_000,
|
||||
"{} points added: refinement ran to its budget",
|
||||
@@ -1400,7 +1409,7 @@ fn triangles_standing_across_a_curved_sheet_are_flipped_along_it() {
|
||||
.sum()
|
||||
};
|
||||
let before = area(&tris);
|
||||
sag::flip(&s, 0.1, 1e-9, &[], &uv, &mut tris).unwrap();
|
||||
sag::flip(&s, 0.1, &mut sag::Poles::new(&s, 1e-9), &[], &uv, &mut tris).unwrap();
|
||||
let after = area(&tris);
|
||||
let sides: Vec<(u32, u32)> = tris
|
||||
.iter()
|
||||
@@ -2138,3 +2147,19 @@ fn an_edge_stopped_at_its_depth_bound_is_named_within_its_tolerance() {
|
||||
assert!(capped.within && capped.sag <= capped.tol, "{capped:?}");
|
||||
assert!(capped.excess > 1.0, "{capped:?}");
|
||||
}
|
||||
|
||||
/// Refining a spline face moves no triangle count: the memo of the pole classifier only drops the
|
||||
/// repeated question (the coil took 1,089,434 / 1,091,452 calls at display / export before it).
|
||||
#[test]
|
||||
fn the_spline_coil_keeps_its_triangles() {
|
||||
let name = "smlib_coil_lower_half";
|
||||
let (b, size) = (fixture(name), size_of(name));
|
||||
for (t, want) in [
|
||||
(MeshTolerance::display_of(size), 30_776),
|
||||
(MeshTolerance::export_of(size), 30_808),
|
||||
] {
|
||||
let (p, _) = plan(&b, &t, size).unwrap();
|
||||
let (m, _) = p.evaluate();
|
||||
assert_eq!(m.triangles.len(), want);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | `FilletBrep::calc_join_section` | `SmFilletVertex::CalcJoinSection` (543d25a) |
|
||||
//! | `FilletBrep::calc_rail_int_end_surface` | `SmFilletVertex::CalcRailIntEndSurface` (`SmBlendCornerEnd.cpp`, 3e26f76) |
|
||||
//! | [`mixed`] | the Mixed corner (`ClassifyMixed`, `MakeMixedTopology`, `CalcMixedVertGeom`, `CalcMixedEdgeGeom`, `CreateMixedPatches`; 6fd0411, 6e79de5, e1a2153) |
|
||||
//! | [`on_face`] | none: a Setback's spring curve kept inside its trimmed face (solidcore#2343 (d)) |
|
||||
//! | [`setback`] | the Setback (`ClassifySetBack`, `MakeSetBackTopology`, `BuildSetBackFill`, `CreateSetBackPatches`; 61547af, 1506e2f, f1d81f4) |
|
||||
//! | [`swept`] | the Swept turn (`Classify`'s Swept part, `BuildSweptSurface`, `CreateSweptPatch`) |
|
||||
//! | [`BlendChamferCorner`], [`chamfer`] | not the fork's: chamfers meeting at a vertex, closed by their planes (WR-716, #2345) |
|
||||
@@ -69,6 +70,7 @@ pub mod ball;
|
||||
pub mod chamfer;
|
||||
pub mod end;
|
||||
pub mod mixed;
|
||||
pub mod on_face;
|
||||
pub mod setback;
|
||||
pub mod swept;
|
||||
|
||||
|
||||
@@ -1423,7 +1423,8 @@ pub fn calc_mixed_edge_geom(
|
||||
Point3::new(to.0, to.1, 0.0),
|
||||
)?;
|
||||
let side_i = *nth(if turn { &which_t } else { &which }, kk, OP_EDGE)?;
|
||||
*nth_mut(of2m(&mut side_curves, f), side_i, OP_EDGE)? = Some(iso.clone());
|
||||
*nth_mut(of2m(&mut side_curves, f).as_mut_slice(), side_i, OP_EDGE)? =
|
||||
Some(iso.clone());
|
||||
of2m(&mut state.curves_3d, f).push(Curve::from(iso));
|
||||
of2m(&mut state.uv_curves, f).push(Curve::from(uv));
|
||||
of2m(&mut state.orients, f).push(*nth(&orients, kk, OP_EDGE)?);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Whether a Setback's spring curve stays inside its trimmed face: it comes within no gap of
|
||||
//! any of the face's other edges (solidcore#2343 (d), the owner's ruling B there).
|
||||
//!
|
||||
//! Upstream: none. The fork's ruling on smlib#85 part C asked that the spring curve's
|
||||
//! parameter-plane control triangle `(P^, X^, Q^)` lie inside the trimmed face; that is the
|
||||
//! convex-hull bound for the curve itself, sound but loose (it refuses setbacks whose spring
|
||||
//! curve stays on the face). Ruling B on solidcore#2343 checks the curve.
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |-------------------|----------|
|
||||
//! | [`Clearance`] | none |
|
||||
//! | [`bezier_pieces`] | none |
|
||||
//! | [`clear_of`] | none |
|
||||
//!
|
||||
//! Decided on control points, never at sample points (`docs/definitions-and-certificates.md`
|
||||
//! §1): a rational Bézier piece with positive weights lies in the convex hull of its
|
||||
//! control points (Farin, *Curves and Surfaces for CAGD*). Two pieces whose hulls are
|
||||
//! proved farther apart than the gap and both pieces' rounding (their boxes', or their gap
|
||||
//! along the line between their centroids: `apart`) come no nearer than the gap. Otherwise the wider of the two is halved (de Casteljau on its
|
||||
//! homogeneous points) and the halves are tried in turn. A piece halved [`DEPTH`] times is
|
||||
//! halved no further, and a pair still undecided then, or once [`PAIRS`] pairs have been
|
||||
//! tried, is [`Clearance::Near`]: an undecided spring curve is not taken as clear. The
|
||||
//! rounding is Higham's `gamma` of the operations each point has been through, as
|
||||
//! `blend_setback::containment` bounds its patches.
|
||||
//!
|
||||
//! Deviations: no recursion: the pairs are kept on an explicit stack, each piece halved at
|
||||
//! most [`DEPTH`] times, at most [`PAIRS`] pairs per curve and piece list.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::curve::curve::{Curve, CurveKind};
|
||||
use crate::error::Result;
|
||||
use crate::geom::vector3d::sqrt;
|
||||
use crate::kernel::Kernel;
|
||||
use crate::math::Vec3;
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::nurbs::math::{gamma, NOW, NOZ};
|
||||
|
||||
const OP: &str = "blend::blend_corner::on_face::clear_of";
|
||||
|
||||
/// A homogeneous point `(x w, y w, z w, w)`.
|
||||
pub type Hom = [f64; 4];
|
||||
|
||||
/// How many times a piece is halved at most: a piece of a part's size, tens of millimetres,
|
||||
/// is then under 1e-10 long, the rounding of the part's coordinates (§4.4 of the
|
||||
/// definitions doc).
|
||||
pub const DEPTH: usize = 40;
|
||||
|
||||
/// How many pairs of pieces [`clear_of`] tries at most for one curve against one list of
|
||||
/// pieces. Near a closest approach a few pairs stay at each halving, so a decided case needs
|
||||
/// a few hundred.
|
||||
pub const PAIRS: usize = 4096;
|
||||
|
||||
/// Whether one curve comes within a gap of another ([`clear_of`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Clearance {
|
||||
/// Proved to stay farther than the gap from the other everywhere.
|
||||
Clear,
|
||||
/// Within the gap somewhere, or not shown not to be within [`DEPTH`] and [`PAIRS`].
|
||||
Near,
|
||||
}
|
||||
|
||||
/// The rational Bézier pieces of `curve` as homogeneous points, one piece per knot span: of
|
||||
/// the B-spline a line, a conic or a B-spline curve is built on (the whole of it, which holds
|
||||
/// an edge it is trimmed to; a line's or an ellipse's made on a copy where it holds none
|
||||
/// yet). `None` for a curve of another kind, a planar (2D) one, one whose B-spline is not
|
||||
/// made, or one whose weights are not all positive and finite: such a curve has no hull to
|
||||
/// bound it by.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Those of the B-spline's decomposition into its Bézier pieces; a cancellation while an
|
||||
/// ellipse's is made.
|
||||
pub fn bezier_pieces(k: &Kernel, curve: &Curve) -> Result<Option<Vec<Vec<Hom>>>> {
|
||||
let base: Cow<'_, BSplineCurve> = match curve.kind() {
|
||||
CurveKind::BSpline(c) => Cow::Borrowed(c),
|
||||
CurveKind::Line(c) => c.base_with_nurb()?,
|
||||
CurveKind::Ellipse(c) if c.base().nurb.is_none() => {
|
||||
let mut copy = c.clone();
|
||||
match copy.make_nurb(k) {
|
||||
Err(e) if e.is_unported_or_cancelled() => return Err(e),
|
||||
Err(_) => return Ok(None),
|
||||
Ok(()) => Cow::Owned(copy.base().clone()),
|
||||
}
|
||||
}
|
||||
CurveKind::Ellipse(c) => Cow::Borrowed(c.base()),
|
||||
CurveKind::Hyperbola(c) => Cow::Borrowed(&c.base),
|
||||
CurveKind::Parabola(c) => Cow::Borrowed(c.base()),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let Some(nurb) = base.nurb.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut pieces = Vec::new();
|
||||
for bez in nurb.decompose_bez()? {
|
||||
let mut piece = Vec::new();
|
||||
for cp in bez.cpts() {
|
||||
if cp.z == NOZ {
|
||||
return Ok(None);
|
||||
}
|
||||
let h = if cp.w == NOW {
|
||||
[cp.x, cp.y, cp.z, 1.0]
|
||||
} else {
|
||||
[cp.x, cp.y, cp.z, cp.w]
|
||||
};
|
||||
if !h.iter().all(|c| c.is_finite()) || h[3] <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
piece.push(h);
|
||||
}
|
||||
pieces.push(piece);
|
||||
}
|
||||
Ok(Some(pieces))
|
||||
}
|
||||
|
||||
/// A piece on the stack: its homogeneous points, how many more times it may be halved, and
|
||||
/// the rounding bound of its Euclidean points (that of the piece it was halved from).
|
||||
#[derive(Clone)]
|
||||
struct Piece {
|
||||
points: Vec<Hom>,
|
||||
depth: usize,
|
||||
rounding: f64,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
/// A piece as given: its Euclidean points are within `rounding` of the exact ones after
|
||||
/// [`DEPTH`] halvings of `degree` averaging steps each and the division by the weight:
|
||||
/// `3 gamma(ops + 2)` of its largest homogeneous coordinate over its least weight (shrunk
|
||||
/// by the weights' own rounding), `ops = 8 + DEPTH degree` (the 8 for the knot insertions
|
||||
/// that cut a B-spline into its pieces and the division). `None` where a weight is not
|
||||
/// positive or a coordinate not finite.
|
||||
fn new(points: &[Hom]) -> Option<Self> {
|
||||
let degree = points.len().checked_sub(1)?;
|
||||
let (mut most, mut w_min) = (0.0f64, f64::INFINITY);
|
||||
for h in points {
|
||||
if !h.iter().all(|c| c.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
most = most.max(h[0].abs()).max(h[1].abs()).max(h[2].abs());
|
||||
w_min = w_min.min(h[3]);
|
||||
}
|
||||
let ops = 8 + DEPTH * degree;
|
||||
let shrink = 1.0 - gamma(ops);
|
||||
if !(w_min > 0.0 && shrink > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
points: points.to_vec(),
|
||||
depth: DEPTH,
|
||||
rounding: 3.0 * gamma(ops + 2) * most / (w_min * shrink),
|
||||
})
|
||||
}
|
||||
|
||||
/// Its Euclidean points; `None` where a weight is not positive.
|
||||
fn euclidean(&self) -> Option<Vec<Vec3>> {
|
||||
let mut out = Vec::new();
|
||||
for h in &self.points {
|
||||
if h[3].is_nan() || h[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
out.push(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Its two halves at the middle of its parameter (de Casteljau), each halvable once less.
|
||||
fn halves(&self) -> (Self, Self) {
|
||||
let d = self.points.len().saturating_sub(1);
|
||||
let mut work = self.points.clone();
|
||||
let mut left = Vec::new();
|
||||
let mut right = Vec::new();
|
||||
for k in 0..=d {
|
||||
if let Some(&h) = work.first() {
|
||||
left.push(h);
|
||||
}
|
||||
if let Some(&h) = work.get(d - k) {
|
||||
right.push(h);
|
||||
}
|
||||
for i in 0..d - k {
|
||||
if let (Some(&a), Some(&b)) = (work.get(i), work.get(i + 1)) {
|
||||
if let Some(w) = work.get_mut(i) {
|
||||
*w = [
|
||||
0.5 * (a[0] + b[0]),
|
||||
0.5 * (a[1] + b[1]),
|
||||
0.5 * (a[2] + b[2]),
|
||||
0.5 * (a[3] + b[3]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
right.reverse();
|
||||
let depth = self.depth.saturating_sub(1);
|
||||
(
|
||||
Self {
|
||||
points: left,
|
||||
depth,
|
||||
rounding: self.rounding,
|
||||
},
|
||||
Self {
|
||||
points: right,
|
||||
depth,
|
||||
rounding: self.rounding,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest extent of `points` along an axis.
|
||||
fn width(points: &[Vec3]) -> f64 {
|
||||
let (lo, hi) = extent(points);
|
||||
(hi.x - lo.x).max(hi.y - lo.y).max(hi.z - lo.z)
|
||||
}
|
||||
|
||||
/// The box of `points`, its least and greatest corners.
|
||||
fn extent(points: &[Vec3]) -> (Vec3, Vec3) {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in points {
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// A lower bound on the distance between the convex hulls of `a` and `b`, less its own
|
||||
/// rounding: the larger of the distance between their boxes and their gap along the line
|
||||
/// between their centroids (any unit direction `u` separates them by `min u . b - max u . a`;
|
||||
/// that one closes on the hulls' distance as near-parallel pieces shrink, where the boxes'
|
||||
/// does not). The boxes' gaps are rounded by `2 gamma(1)` of the largest coordinate `s`, their
|
||||
/// sum of squares by `gamma(6)`; each projection by `gamma(3) sqrt 3 s |u|`, and the quotient
|
||||
/// by `|u|` by `gamma(4)`.
|
||||
fn apart(a: &[Vec3], b: &[Vec3]) -> f64 {
|
||||
let ((alo, ahi), (blo, bhi)) = (extent(a), extent(b));
|
||||
let size = [alo, ahi, blo, bhi].iter().fold(0.0f64, |m, c| {
|
||||
m.max(c.x.abs()).max(c.y.abs()).max(c.z.abs())
|
||||
});
|
||||
let gap = |l: f64, h: f64| l - h;
|
||||
let mut squares = 0.0;
|
||||
for d in [
|
||||
gap(blo.x, ahi.x).max(gap(alo.x, bhi.x)),
|
||||
gap(blo.y, ahi.y).max(gap(alo.y, bhi.y)),
|
||||
gap(blo.z, ahi.z).max(gap(alo.z, bhi.z)),
|
||||
] {
|
||||
let d = (d - 2.0 * gamma(1) * size).max(0.0);
|
||||
squares += d * d;
|
||||
}
|
||||
let boxes = sqrt(squares * (1.0 - gamma(6))) * (1.0 - gamma(2));
|
||||
let centroid = |p: &[Vec3]| {
|
||||
let (sum, count) = p
|
||||
.iter()
|
||||
.fold((Vec3::ZERO, 0.0), |(s, c), &q| (s + q, c + 1.0));
|
||||
sum * (1.0 / count)
|
||||
};
|
||||
let w = centroid(b) - centroid(a);
|
||||
// the line between the centroids, and its parts square to each piece's chord (for
|
||||
// near-parallel pieces side by side, whatever their shift along each other)
|
||||
let square_to = |p: &[Vec3]| match (p.first(), p.last()) {
|
||||
(Some(&first), Some(&last)) => {
|
||||
let t = last - first;
|
||||
let tt = t.dot(t);
|
||||
if tt > 0.0 {
|
||||
w - t * (w.dot(t) / tt)
|
||||
} else {
|
||||
w
|
||||
}
|
||||
}
|
||||
_ => w,
|
||||
};
|
||||
let mut best = boxes;
|
||||
for u in [w, square_to(a), square_to(b)] {
|
||||
let length = u.length();
|
||||
if length.is_nan() || length <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let reach_a = a.iter().fold(f64::NEG_INFINITY, |m, p| m.max(u.dot(*p)));
|
||||
let reach_b = b.iter().fold(f64::INFINITY, |m, p| m.min(u.dot(*p)));
|
||||
let along = (reach_b - reach_a) / length * (1.0 - gamma(4))
|
||||
- 2.0 * gamma(3) * sqrt(3.0) * size * (1.0 + gamma(4));
|
||||
best = best.max(along);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether the rational Bézier curve `curve` (its homogeneous Bernstein coefficients) comes
|
||||
/// within `gap` of any of `pieces` (each such a curve too): [`Clearance::Clear`] only where
|
||||
/// that is proved on the pieces' control points (module docs).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The kernel's cancellation.
|
||||
pub fn clear_of(k: &Kernel, curve: &[Hom], pieces: &[Vec<Hom>], gap: f64) -> Result<Clearance> {
|
||||
let Some(a) = Piece::new(curve) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
let mut stack = Vec::new();
|
||||
for p in pieces {
|
||||
let Some(b) = Piece::new(p) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
stack.push((a.clone(), b));
|
||||
}
|
||||
let mut tried = 0;
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
k.check_cancelled(OP)?;
|
||||
tried += 1;
|
||||
if tried > PAIRS {
|
||||
return Ok(Clearance::Near);
|
||||
}
|
||||
let (Some(pa), Some(pb)) = (a.euclidean(), b.euclidean()) else {
|
||||
return Ok(Clearance::Near);
|
||||
};
|
||||
if apart(&pa, &pb) > gap + a.rounding + b.rounding {
|
||||
continue;
|
||||
}
|
||||
let (wa, wb) = (width(&pa), width(&pb));
|
||||
// the wider is halved, where it may be; neither: undecided
|
||||
let halve_a = a.depth > 0 && (wa >= wb || b.depth == 0);
|
||||
if halve_a {
|
||||
let (a0, a1) = a.halves();
|
||||
stack.push((a1, b.clone()));
|
||||
stack.push((a0, b));
|
||||
} else if b.depth > 0 {
|
||||
let (b0, b1) = b.halves();
|
||||
stack.push((a.clone(), b1));
|
||||
stack.push((a, b0));
|
||||
} else {
|
||||
return Ok(Clearance::Near);
|
||||
}
|
||||
}
|
||||
Ok(Clearance::Clear)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A quarter circle of radius `r` about `(cx, cy)` in z = 0, from angle 0 to 90 degrees:
|
||||
/// the rational quadratic with middle weight `cos 45`.
|
||||
fn quarter(cx: f64, cy: f64, r: f64) -> Vec<Hom> {
|
||||
let w = 0.5f64.sqrt();
|
||||
vec![
|
||||
[cx + r, cy, 0.0, 1.0],
|
||||
[(cx + r) * w, (cy + r) * w, 0.0, w],
|
||||
[cx, cy + r, 0.0, 1.0],
|
||||
]
|
||||
}
|
||||
|
||||
/// A segment from `a` to `b`, degree 1.
|
||||
fn segment(a: [f64; 3], b: [f64; 3]) -> Vec<Hom> {
|
||||
vec![[a[0], a[1], a[2], 1.0], [b[0], b[1], b[2], 1.0]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_near_a_circle_is_clear_by_more_than_the_gap_and_near_within_it() {
|
||||
let k = Kernel::default();
|
||||
let arc = vec![quarter(0.0, 0.0, 10.0)];
|
||||
// the line x + y = c outside the circle is closest to the arc at 45 degrees, c / sqrt 2
|
||||
// - 10 away, and runs along it within twice that for a hundredth of a turn
|
||||
let c = 2.0f64.sqrt() * (10.0 + 1e-3);
|
||||
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
|
||||
assert_eq!(
|
||||
clear_of(&k, &chord, &arc, 0.9e-3).unwrap(),
|
||||
Clearance::Clear
|
||||
);
|
||||
assert_eq!(clear_of(&k, &chord, &arc, 1.1e-3).unwrap(), Clearance::Near);
|
||||
// inside it, crossing it twice: near at any gap
|
||||
let c = 2.0f64.sqrt() * (10.0 - 1e-3);
|
||||
let chord = segment([c, 0.0, 0.0], [0.0, c, 0.0]);
|
||||
assert_eq!(clear_of(&k, &chord, &arc, 0.0).unwrap(), Clearance::Near);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_far_apart_are_clear_at_once_and_a_bad_weight_is_near() {
|
||||
let k = Kernel::default();
|
||||
let far = vec![quarter(100.0, 0.0, 1.0)];
|
||||
let chord = segment([0.0, 0.0, 0.0], [1.0, 1.0, 0.0]);
|
||||
assert_eq!(clear_of(&k, &chord, &far, 1e-6).unwrap(), Clearance::Clear);
|
||||
let mut bad = quarter(100.0, 0.0, 1.0);
|
||||
bad[1][3] = 0.0;
|
||||
assert_eq!(clear_of(&k, &chord, &[bad], 1e-6).unwrap(), Clearance::Near);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user