mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dde4647b1 | |||
| 53ce0e47b1 | |||
| f46eae28c7 | |||
| ea4164dabb | |||
| af16e55217 | |||
| c5fd9bc518 | |||
| 7cd4bc3b2c | |||
| 0d0a7327d9 | |||
| ff6ca84915 | |||
| eabbbc4c3d | |||
| 0917a5dec2 | |||
| c7fb4f7cab | |||
| 089d09e40a |
@@ -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 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
|
||||
- name: Claim, triage and PR-check bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.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:
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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);
|
||||
+25
-8
@@ -17,15 +17,28 @@ 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 is paused
|
||||
## Kernel work goes in solidcore/
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -429,6 +442,10 @@ 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: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], 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: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d.x != 0.0 {
|
||||
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 {
|
||||
t.axis_x
|
||||
} else if d.y != 0.0 {
|
||||
} else if d[1] != 0.0 {
|
||||
t.axis_y
|
||||
} else {
|
||||
t.axis_z
|
||||
|
||||
+144
-8
@@ -1163,6 +1163,9 @@ 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
|
||||
@@ -1479,7 +1482,10 @@ fn chain_fingerprint(
|
||||
steps: &ImportDigests,
|
||||
st: Option<&ModelState>,
|
||||
) -> u64 {
|
||||
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
|
||||
// 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;
|
||||
// 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();
|
||||
@@ -1793,6 +1799,70 @@ 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.
|
||||
@@ -2193,10 +2263,11 @@ 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
|
||||
r.id == f.id && r.fingerprint == prev && posed
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2264,6 +2335,10 @@ 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() {
|
||||
@@ -2292,7 +2367,12 @@ 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 current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
|
||||
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;
|
||||
// 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);
|
||||
@@ -2303,14 +2383,28 @@ 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| old.skipped || legacy_current(doc, f, &state, &old.state))
|
||||
&& 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))
|
||||
{
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
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() });
|
||||
}
|
||||
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 {
|
||||
@@ -2386,6 +2480,14 @@ 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(),
|
||||
@@ -2407,8 +2509,9 @@ 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().and_then(|p| p.get(&f.id)).filter(current))
|
||||
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
|
||||
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
|
||||
{
|
||||
restored += 1;
|
||||
@@ -2549,6 +2652,14 @@ 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(),
|
||||
@@ -2566,6 +2677,7 @@ 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;
|
||||
@@ -6959,6 +7071,30 @@ 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,7 +917,10 @@ 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();
|
||||
if unkept > 0 {
|
||||
// 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 {
|
||||
// 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);
|
||||
@@ -933,6 +936,12 @@ 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 {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
|
||||
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
|
||||
if depth > crate::MAX_PLACEMENT_DEPTH {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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};
|
||||
|
||||
@@ -36,6 +37,8 @@ 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))
|
||||
@@ -173,15 +176,19 @@ 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", split_face)
|
||||
CommandSpec::new("solid.split_face", "Split Face", crate::checked!("solid.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", split_body)
|
||||
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.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,9 +63,11 @@ 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", export)
|
||||
CommandSpec::new("file.export", "Export", crate::checked!("file.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
|
||||
@@ -128,6 +130,7 @@ 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,3 +1239,31 @@ 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,6 +15,7 @@ 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,
|
||||
@@ -63,6 +64,7 @@ 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();
|
||||
@@ -123,8 +125,10 @@ 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", draw_dimensioned).icon("dimension")
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", crate::checked!("sketch.draw.dimensioned", 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,6 +17,7 @@ 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
|
||||
@@ -135,13 +136,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut out = scratch.execute(create, ¶ms)?;
|
||||
let 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::new();
|
||||
let mut defined: Vec<String> = 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"),
|
||||
@@ -187,8 +188,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").filter(|v| v.is_string()) {
|
||||
defined.push(name.clone());
|
||||
if let Some(name) = result.get("defined").and_then(Value::as_str) {
|
||||
defined.push(name.to_string());
|
||||
}
|
||||
added.insert(role.to_string(), result);
|
||||
}
|
||||
@@ -212,14 +213,8 @@ 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);
|
||||
out["dimensions"] = Value::Object(added);
|
||||
if !defined.is_empty() {
|
||||
out["defined"] = Value::Array(defined);
|
||||
}
|
||||
if locate {
|
||||
out["located"] = Value::Array(located);
|
||||
}
|
||||
Ok(out)
|
||||
let reply = DrawDimensionedResult::new(out, added, defined, locate.then_some(located));
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The sketch's degrees of freedom as a command reported them.
|
||||
|
||||
@@ -331,7 +331,13 @@ 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() => {
|
||||
o.insert("param_warnings".into(), json!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>()));
|
||||
// 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));
|
||||
Ok(Value::Object(o))
|
||||
}
|
||||
other => other,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! 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`.
|
||||
//! 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`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use schemars::{JsonSchema, Schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_io::Format;
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::StringList;
|
||||
|
||||
/// How closely an exported mesh follows curved faces.
|
||||
@@ -93,6 +97,137 @@ 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 {
|
||||
@@ -100,7 +235,8 @@ pub struct ExportResult {
|
||||
pub path: String,
|
||||
/// Its size in bytes.
|
||||
pub bytes: usize,
|
||||
/// The format written: Step, StlBinary, ThreeMf or Obj.
|
||||
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` also StlAscii,
|
||||
/// ThreeMfTagged or Design).
|
||||
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")]
|
||||
@@ -130,6 +266,16 @@ 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::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Export3mfParams, ExportMeshParams, ExportStepParams, ExportUrdfParams};
|
||||
use super::{Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::assert_keys;
|
||||
use crate::schema::{ParamCheck, input_schema_for};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
@@ -11,6 +11,7 @@ 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]
|
||||
@@ -36,3 +37,148 @@ 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,6 +115,7 @@ 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;
|
||||
@@ -123,6 +124,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,234 @@
|
||||
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]));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,210 @@
|
||||
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,6 +974,42 @@ 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,7 +7,6 @@ doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
face.delete
|
||||
file.export
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
@@ -24,7 +23,6 @@ render.preflight
|
||||
render.scene
|
||||
render.start
|
||||
sheet.export_dxf
|
||||
sketch.draw.dimensioned
|
||||
solid.align
|
||||
solid.coil
|
||||
solid.draft
|
||||
@@ -41,8 +39,6 @@ solid.press_pull
|
||||
solid.replace_face
|
||||
solid.revolve
|
||||
solid.shell
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.thread
|
||||
surface.blend
|
||||
surface.extend
|
||||
|
||||
@@ -400,3 +400,124 @@ 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",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
tr(match self {
|
||||
State::Off => "ui.agent_chat_state_off",
|
||||
State::Starting => "ui.agent_chat_state_starting",
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -240,7 +241,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) {
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
let rect = ui.max_rect().intersect(ui.clip_rect());
|
||||
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
|
||||
return;
|
||||
@@ -248,7 +249,7 @@ pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
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));
|
||||
ui_kit::scope(ui, |ui| content(app, ui, flow));
|
||||
}
|
||||
|
||||
/// Horizontal room reserved before the history and browser when Assistant is on the left.
|
||||
@@ -904,10 +905,12 @@ 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);
|
||||
content(app, ui, Flow::Tall);
|
||||
}
|
||||
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = flow;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
@@ -917,17 +920,61 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.label(tr("ui.agent_chat_desktop_only"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
desktop_body(app, ui);
|
||||
desktop_body(app, ui, flow);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
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| {
|
||||
@@ -935,7 +982,7 @@ fn desktop_body(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_handle("agent_chat:new_confirm", yes.rect.center());
|
||||
crate::scenario::publish_target("agent_chat:new_confirm", yes.rect);
|
||||
if yes.clicked() {
|
||||
new_chat(app);
|
||||
}
|
||||
@@ -946,6 +993,23 @@ fn desktop_body(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 {
|
||||
@@ -956,24 +1020,41 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
ui.available_height() / 3.0
|
||||
};
|
||||
let picker = egui::ScrollArea::vertical()
|
||||
let provider_scroll = egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_picker_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
.max_height(picker_max)
|
||||
.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));
|
||||
}
|
||||
});
|
||||
.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));
|
||||
}
|
||||
});
|
||||
if !shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
|
||||
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 {
|
||||
// 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
|
||||
@@ -1148,6 +1229,11 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
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)
|
||||
@@ -1163,7 +1249,12 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
scroll_while_selecting(ui);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
|
||||
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
|
||||
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;
|
||||
if esc || stop_clicked {
|
||||
cancel(app);
|
||||
}
|
||||
@@ -1174,13 +1265,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// 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, ui.ctx(), text);
|
||||
send(app, ctx, text);
|
||||
if steer {
|
||||
send_now(app, ui.ctx());
|
||||
send_now(app, ctx);
|
||||
}
|
||||
} else if steer {
|
||||
// Nothing typed: Ctrl+Enter sends what is already queued.
|
||||
send_now(app, ui.ctx());
|
||||
send_now(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2550,7 +2641,7 @@ mod tests {
|
||||
ui.set_width(width);
|
||||
ui_kit::scope(ui, |ui| {
|
||||
header(&mut app, ui, false);
|
||||
desktop_body(&mut app, ui);
|
||||
desktop_body(&mut app, ui, Flow::Tall);
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
|
||||
@@ -4762,3 +4762,249 @@ 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, at, 0.5, false, t));
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, d.into(), 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,6 +192,7 @@ 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;
|
||||
@@ -318,6 +319,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 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,3 +622,186 @@ 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::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
|
||||
use crate::ui_kit::{self, NavigationEdge, NavigationKind};
|
||||
use crate::{SolveApp, viewport::NavMode};
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
@@ -259,21 +259,62 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
/// 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 {
|
||||
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.is_some() {
|
||||
} else if slot.id == "zoom" && key {
|
||||
app.tr("navbar.zoom_hold_tip").to_owned()
|
||||
} else {
|
||||
app.tr(slot.label).to_owned()
|
||||
};
|
||||
ui_kit::navigation_tooltip(&response, along, edge, &label, key.as_deref());
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
@@ -281,107 +322,73 @@ 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 bar = measured(app, ui.ctx(), layout.content);
|
||||
if !bar.rect.is_positive() {
|
||||
let measured = measured(app, ui.ctx(), layout.content);
|
||||
if !measured.rect.is_positive() {
|
||||
return;
|
||||
}
|
||||
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))
|
||||
let active = 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
|
||||
|| !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
|
||||
|| 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(),
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
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() };
|
||||
}
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
NavAction::Context(ContextItem::Customise) => {
|
||||
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
|
||||
}
|
||||
NavAction::Context(ContextItem::Hide) => {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":false}));
|
||||
}
|
||||
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 {
|
||||
@@ -409,250 +416,131 @@ pub(crate) fn paint_row(
|
||||
open: &str,
|
||||
editing: Option<(Option<usize>, NavCaps, bool)>,
|
||||
) -> Vec<(usize, &'static Slot, egui::Response)> {
|
||||
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)));
|
||||
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;
|
||||
}
|
||||
responses
|
||||
// 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()
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
/// 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", "");
|
||||
later.enabled = false;
|
||||
later.detail = Some(app.tr("navbar.later"));
|
||||
later.show(ui);
|
||||
chosen
|
||||
later.detail = Some(app.tr("navbar.later").to_owned());
|
||||
entries.push(MenuEntry::Row(later));
|
||||
Menu { entries, close_on_pick: true }
|
||||
}
|
||||
|
||||
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"));
|
||||
/// 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())];
|
||||
let current = crate::prefs::graphics_preset(&app.preferences);
|
||||
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"));
|
||||
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())]);
|
||||
for id in ["grid", "ground_shadow", "ambient_occlusion"] {
|
||||
let Some(slot) = navigation_bar::find(id) else { continue };
|
||||
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.push(MenuEntry::Row(row(
|
||||
slot.id,
|
||||
app.tr(slot.label),
|
||||
slot.current_icon(app.ui.visual_style),
|
||||
&format!("view:{id}"),
|
||||
selected(app, slot.id),
|
||||
)));
|
||||
}
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.projection_heading"));
|
||||
entries.extend([MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.projection_heading").to_owned())]);
|
||||
for (perspective, key) in [(false, "navbar.orthographic"), (true, "navbar.perspective")] {
|
||||
let mut item = NavigationMenuRow::new(key, app.tr(key), "perspective");
|
||||
item.selected = app.ui.perspective == perspective;
|
||||
let mut item = row(
|
||||
key,
|
||||
app.tr(key),
|
||||
"perspective",
|
||||
if perspective { "view:projection" } else { "view:orthographic" },
|
||||
app.ui.perspective == perspective,
|
||||
);
|
||||
item.enabled = !projection_locked(app);
|
||||
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);
|
||||
}
|
||||
item.tip = projection_locked(app).then(|| app.tr("nav.perspective.sketch").to_owned());
|
||||
entries.push(MenuEntry::Row(item));
|
||||
}
|
||||
separator(ui);
|
||||
entries.push(MenuEntry::Separator);
|
||||
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),
|
||||
] {
|
||||
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,
|
||||
}
|
||||
}
|
||||
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 = 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;
|
||||
}
|
||||
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) {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
"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" => {
|
||||
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,7 +682,9 @@ impl AppPanelRuntime {
|
||||
if (self.runtime.environment() != &before || self.runtime.layout().inspect().revision != revision)
|
||||
&& let Some(host) = &mut self.host
|
||||
{
|
||||
host.invalidate_context();
|
||||
// Presentation metadata can revise an unrelated panel.
|
||||
// Scope and focused-owner checks decide input ownership.
|
||||
host.invalidate_geometry();
|
||||
host.reconcile_scope(&self.runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,17 @@ 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()
|
||||
@@ -854,7 +865,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),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
|
||||
}
|
||||
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,6 +12893,92 @@ 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,6 +226,17 @@ 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)
|
||||
@@ -600,6 +611,16 @@ 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 {
|
||||
@@ -935,7 +956,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) in &unpainted {
|
||||
for (layer, bounds, sizing) in &unpainted {
|
||||
let hidden_sections: Vec<_> = frame
|
||||
.layout
|
||||
.sections
|
||||
@@ -946,6 +967,16 @@ 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.
|
||||
@@ -1032,6 +1063,12 @@ 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()));
|
||||
@@ -1040,11 +1077,8 @@ impl WorkspacePanelHost {
|
||||
} else if current.is_none()
|
||||
&& let Some(bookmark) = &self.content_focus
|
||||
{
|
||||
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);
|
||||
let eligible =
|
||||
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));
|
||||
@@ -2196,7 +2230,7 @@ struct WorkspaceContentHost<'a> {
|
||||
motion: Motion,
|
||||
commands: &'a mut Vec<HostCommand>,
|
||||
allow_actions: bool,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect)>,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect, bool)>,
|
||||
}
|
||||
|
||||
impl PanelHost for WorkspaceContentHost<'_> {
|
||||
@@ -2247,7 +2281,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));
|
||||
self.unpainted.push((ui.layer_id(), section.outer, ui.is_sizing_pass()));
|
||||
return;
|
||||
}
|
||||
// A surface response provides semantic focus and owns only the actual
|
||||
|
||||
@@ -350,13 +350,14 @@ 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 + axis.screen * (38.0 * 0.84 * scale))).collect();
|
||||
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();
|
||||
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.into(), *at, axis.depth, false, t, scale);
|
||||
crate::icons::navigation::axis_tip_scaled(&p, c, axis.name, axis.direction, *at, axis.depth, false, t, scale);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+49
-124
@@ -7240,99 +7240,58 @@ pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
|
||||
})
|
||||
}
|
||||
|
||||
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;
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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 (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)]
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
#[cfg(test)]
|
||||
fn axis_hit(tips: &[crate::view_compass::Tip], p: Pos2) -> Option<usize> {
|
||||
crate::view_compass::hit(tips, p)
|
||||
}
|
||||
|
||||
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
|
||||
/// Dragging anywhere on the view navigator 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 (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 && ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p)) {
|
||||
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);
|
||||
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),
|
||||
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)
|
||||
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.axis_drag, app.viewport.axis_warp) = (drag.at, drag.warp);
|
||||
if orbit.grabbed || orbit.released {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
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 {
|
||||
if let Some(crate::view_compass::OrbitStep { dx, dy, snap }) = orbit.step {
|
||||
app.cancel_view_animation();
|
||||
// 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_drag(app, rect, dx, dy, None, snap, false);
|
||||
}
|
||||
true
|
||||
orbit.orbiting
|
||||
}
|
||||
|
||||
/// The whole navigator's box takes the right-click for the navigator's menu, before the view
|
||||
@@ -7380,64 +7339,30 @@ 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 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 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 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);
|
||||
}
|
||||
}
|
||||
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");
|
||||
Some(crate::view_compass::ViewAction::Home) => app.animate_view("home"),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, painted, 0.5, false, t);
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), 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, painted, 0.5, true, t);
|
||||
crate::icons::navigation::axis_tip(ui.painter(), c, name, direction.into(), 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,6 +44,15 @@ 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.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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,19 +10,22 @@ 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. 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. 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. |
|
||||
| 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 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. |
|
||||
| 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. |
|
||||
| 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, 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Thread direction (Tapped) | Right-hand or left-hand thread. | Right-hand unless the print calls for LH. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -36,11 +39,13 @@ 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 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** 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** 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.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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, which is paused.
|
||||
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
|
||||
|
||||
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,6 +26,14 @@ 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
|
||||
@@ -50,7 +58,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 future History product registration allows Bottom; synthetic kinds can allow
|
||||
Only the 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, paused for now (CONTRIBUTING.md, "Kernel work is paused").
|
||||
which is kernel work, done in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
- 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,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
'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 };
|
||||
@@ -1,109 +0,0 @@
|
||||
'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,12 +54,17 @@ 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) {
|
||||
@@ -151,7 +156,10 @@ 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 === 'missing') {
|
||||
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') {
|
||||
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,6 +47,13 @@ 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 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.
|
||||
- **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.
|
||||
- **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,6 +95,11 @@ 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
|
||||
|
||||
@@ -153,15 +158,14 @@ 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.
|
||||
Read the current kernel-pause notice in CONTRIBUTING.md and skip kernel work
|
||||
while paused, including tasks outside `crates/kernel/` that change kernel behaviour.
|
||||
Kernel and geometry tasks are fine: they go in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
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: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
|
||||
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
|
||||
```
|
||||
|
||||
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 as issues even while kernel work is paused (CONTRIBUTING.md, "Kernel work is paused").
|
||||
Crashes and data loss are taken first, including in kernel code, which lives in `solidcore/` (CONTRIBUTING.md, "Kernel work goes in solidcore/").
|
||||
|
||||
@@ -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) is paused except crashes and data loss: CONTRIBUTING.md, "Kernel work is paused".
|
||||
- Kernel work (booleans, STEP/IGES import and export, tessellation) goes in `solidcore/`: CONTRIBUTING.md, "Kernel work goes in solidcore/".
|
||||
|
||||
## 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`).
|
||||
- The kernel pause in CONTRIBUTING.md ("Kernel work is paused") still applies to `solidcore/`.
|
||||
- Kernel work goes here: CONTRIBUTING.md, "Kernel work goes in solidcore/". Kernel-only PRs target the `kernel` branch.
|
||||
- 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.
|
||||
- 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").
|
||||
- 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").
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user