Merge origin/main (with #86): sketch.dimension reports the solved value in the dimension's unit; the spout test expects the solved 30 and 60 degrees

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 00:13:51 +02:00
commit ade30e9c4e
107 files changed
+4098 -378

No files matched your search

+12
View File
@@ -98,17 +98,29 @@ jobs:
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
-1
View File
@@ -23,7 +23,6 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
pages: write
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
+1
View File
@@ -49,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
Generated
-2
View File
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matext4cgmath"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
dependencies = [
"cgmath",
"katexit",
+1
View File
@@ -98,3 +98,4 @@ debug = 0
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
+9 -2
View File
@@ -1,6 +1,6 @@
# SolveCraft roadmap
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
@@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
+1 -1
View File
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
let std = match r.to_ascii_uppercase().as_str() {
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
_ => None,
};
+18
View File
@@ -61,6 +61,7 @@ fn services() -> Services {
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -69,6 +70,23 @@ fn services() -> Services {
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
+22 -1
View File
@@ -34,6 +34,14 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
pub visible: bool,
/// The component it belongs to (its plane is in that component's frame, so it moves with
/// the component's occurrence); 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
pub component: u64,
}
fn is_root(c: &u64) -> bool {
*c == 0
}
fn half() -> f64 {
@@ -57,7 +65,7 @@ impl Canvas {
let f = if self.flip { -1.0 } else { 1.0 };
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
}
/// Corners in world coordinates.
/// Corners on `plane` (in whatever frame the plane is given).
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
self.corners().map(|p| plane.to_world(p))
}
@@ -66,6 +74,19 @@ impl Canvas {
}
}
impl crate::Document {
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
/// component's occurrence puts it.
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
let p = self.resolve_plane(vals, &c.plane, 0)?;
let m = self.component_transform(c.component);
if crate::is_identity(&m) {
return Ok(p);
}
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
}
}
/// Image kind and pixel size from the file header (PNG or JPEG).
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
+64 -13
View File
@@ -125,7 +125,8 @@ pub struct Extent {
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<String>,
/// Start offset from the sketch plane (expression).
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<String>,
/// Through all bodies in the direction(s) instead of `distance`.
@@ -939,6 +940,66 @@ fn z_axis() -> Vec3 {
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
pub fn input_sketches(&self) -> Vec<u64> {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
| K::SheetBase { sketch, .. }
| K::SheetContour { sketch, .. } => vec![*sketch],
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
K::Sketch { .. }
| K::Pattern { .. }
| K::Fillet { .. }
| K::Chamfer { .. }
| K::Box { .. }
| K::Cylinder { .. }
| K::Sphere { .. }
| K::Torus { .. }
| K::Combine { .. }
| K::Mirror { .. }
| K::Thread { .. }
| K::Shell { .. }
| K::Draft { .. }
| K::ConstructionPlane { .. }
| K::Split { .. }
| K::ConstructionAxis { .. }
| K::ConstructionPoint { .. }
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
| K::Coil { .. }
| K::SheetFlange { .. }
| K::SheetHem { .. }
| K::SheetUnfold { .. }
| K::SheetConvert { .. }
| K::Boss { .. }
| K::Lip { .. }
| K::SnapFit { .. }
| K::Rest { .. }
| K::BoundaryFill { .. }
| K::Remove { .. }
| K::Stitch { .. }
| K::Thicken { .. }
| K::SurfaceExtend { .. }
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
}
}
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
@@ -1115,6 +1176,7 @@ impl FeatureKind {
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
_ => Vec::new(),
}
}
@@ -1619,18 +1681,7 @@ impl Document {
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
for f in &self.features {
match &f.kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
+97 -6
View File
@@ -377,6 +377,10 @@ pub struct Model {
empty: Arc<ModelState>,
/// Features recomputed by the last `evaluate`.
pub last_recomputed: usize,
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
/// configuration row). Their geometry did change since the previous evaluation.
pub last_restored: usize,
/// Results by inputs, shared with this model's copies.
pub cache: Arc<std::sync::Mutex<EvalCache>>,
/// Evaluate everything from scratch (no reuse; tests compare the two).
@@ -467,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
h.finish()
}
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
/// or with projections): it is resolved in its own component's view of them.
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
return 0;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for o in &doc.occurrences {
o.id.hash(&mut h);
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
}
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
h.finish()
}
fn now() -> web_time::Instant {
web_time::Instant::now()
}
@@ -505,11 +525,12 @@ impl Model {
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
let mut reuse = !self.no_cache;
let mut recomputed = 0;
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
// 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();
@@ -565,10 +586,11 @@ impl Model {
continue;
}
// The same feature on the same inputs: its earlier result.
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
if !self.no_cache
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
{
restored += 1;
if c.error.is_none() {
state = c.state.clone();
}
@@ -652,6 +674,7 @@ impl Model {
}
self.results = out;
self.last_recomputed = recomputed;
self.last_restored = restored;
}
}
@@ -1398,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
}
let mut tools = Vec::new();
for r in &regions {
if *operation == Operation::Join {
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
if exact.iter().all(|tool| {
st.bodies
.iter()
.filter(|b| targets.is_empty() || targets.contains(&b.name))
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
}) {
// Preserve a whole touching face: extending into a chamfer replaces
// the sewable seam with a coincident curved intersection.
tools.extend(exact);
continue;
}
}
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
@@ -2281,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
FeatureKind::Sketch { plane, sketch } => {
let fst = state_in_frame(doc, st, f.component);
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
doc.apply_dimension_values(vals, &mut sk)?;
let report = solve(&mut sk);
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
}
if !report.ok() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
}
@@ -2811,6 +2856,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
}
/// The model as a component sees it: bodies and sketches of components placed differently from
/// it are moved into its frame (through both placements), so a sketch on another component's
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
use std::borrow::Cow;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Cow::Borrowed(st);
}
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let rel = |c: u64, body: Option<&str>| {
let m = crate::mat_mul(&inv, &doc.component_transform(c));
match body.and_then(|b| doc.body_offsets.get(b)) {
Some(o) => crate::mat_mul(&m, o),
None => m,
}
};
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
return Cow::Borrowed(st);
}
let mut out = st.clone();
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
if !crate::is_identity(m)
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
{
// Moving a body keeps its faces (and their order): it keeps their names.
let names = crate::naming::face_names(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
nb.names = crate::naming::NameCell::known(names);
*b = nb;
}
}
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
if !crate::is_identity(m)
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
{
s.plane = np;
}
}
Cow::Owned(out)
}
/// The model placed in the world: each component's bodies, sketches and threads through every
/// occurrence of it (bodies of the first placement keep their names; further instances get the
/// occurrence path in brackets). Without components this is the model itself.
+6 -1
View File
@@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
let tolerance = (b.body.size() * 1e-5).max(1e-4);
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
}
Ok(b.name.clone())
}
/// Axes with z along `z` (and x along `x` when given).
+53 -2
View File
@@ -18,13 +18,15 @@
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 5;
pub const FORMAT: u32 = 6;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -37,7 +39,30 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
fn v5_to_v6(v: &mut Value) -> Result<()> {
fn preserve(v: &mut Value) {
match v {
Value::Object(o) => {
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
if old_xz {
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
} else {
o.values_mut().for_each(preserve);
}
}
Value::Array(a) => a.iter_mut().for_each(preserve),
_ => {}
}
}
preserve(v);
Ok(())
}
/// Features in designs with components keep reaching every body.
fn v4_to_v5(v: &mut Value) -> Result<()> {
@@ -198,6 +223,32 @@ mod tests {
assert_eq!(back, d);
}
#[test]
fn old_xz_and_nested_offsets_keep_their_frame() {
let mut doc = Document::new("old XZ");
let id = doc
.add_feature(
crate::FeatureKind::ConstructionPlane {
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
},
None,
)
.unwrap();
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = Value::from("solvecraft/5");
let migrated = read(&json.to_string()).unwrap();
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
let (values, errors) = migrated.param_values();
assert!(errors.is_empty());
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
+1 -1
View File
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
world_state,
state_in_frame, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+6
View File
@@ -34,6 +34,12 @@ impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
}
/// Names already known (a moved copy of a body keeps its faces' names).
pub fn known(names: Arc<Vec<String>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
}
}
/// Deeper chains than this are named by position only (bounded work and stack).
+15 -2
View File
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
match resolve(doc, vals, st, plane, l.kind, &l.source) {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
*at = p;
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
} else {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face `{name}` no longer exists"));
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
return None;
}
if closed {
+1 -1
View File
@@ -899,7 +899,7 @@ impl SheetBody {
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
}
// A hem folds back over the face it starts from (toward the bottom unless flipped).
let up = !top == flip;
let up = top != flip;
let up = !up;
self.flanges.push(SheetFlange {
parent: panel,
+70
View File
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
fn set_back_reports_restored_features() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "40 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
// No change: nothing recomputed and nothing restored.
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -275,3 +295,53 @@ fn parameter_cycles_are_found() {
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
}
/// Features that use a sketch's points or regions go with it (not only profile and path users).
#[test]
fn input_sketches_cover_points_and_patches() {
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
"points": {"sketch": 7, "ids": ["p1"]},
}))
.unwrap();
assert_eq!(hole.input_sketches(), vec![7]);
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
assert_eq!(patch.input_sketches(), vec![9]);
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
assert_eq!(pipe.input_sketches(), vec![72]);
}
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
/// wildcard); this catches a sketch field added to an existing variant.
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let mut with_sketch = Vec::new();
let mut variant = "";
for line in body.lines() {
let t = line.trim();
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
continue;
}
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|| ty.contains("SketchPoints")
|| ty.contains("LoftSection");
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant);
}
}
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
for v in with_sketch {
assert!(
!empty_arm.contains(&format!("K::{v} {{")),
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
);
}
}
@@ -0,0 +1,66 @@
//! Booleans that failed, panicked or hung, replayed from the scripts in
//! `examples/regressions/` (each script's description says what went wrong).
use serde_json::{Value, json};
use crate::Session;
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
s.run_script(&script).unwrap();
s
}
fn volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
}
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
fn slab_pocket_loft() -> (f64, f64) {
let slab = 900.0 * 480.0 * 160.0;
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
(slab - 557.68 * 100.0 * 120.0, loft)
}
#[test]
fn loft_cut_beside_a_round_hole() {
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
let (pocketed, loft) = slab_pocket_loft();
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
let want = pocketed - loft - hole;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
}
#[test]
fn shallow_loft_cut_from_the_top_face() {
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
let (_, loft) = slab_pocket_loft();
let want = 900.0 * 480.0 * 160.0 - loft;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
];
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
let mut split = Session::default();
let script: Value = serde_json::from_str(scripts[0]).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let whole = volume(&split, "Tool");
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
let above = volume(&split, "Tool");
assert!(above < whole - 10.0, "{above} of {whole}");
for script in scripts {
let s = replay(script);
let got = volume(&s, "Tool");
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
}
}
+3 -1
View File
@@ -495,7 +495,9 @@ mod tests {
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
assert!((ymin + 20.0).abs() < 1e-6);
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
+27 -4
View File
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solvecraft_doc::Model::new();
s.refresh();
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let new = format!("({old}) + ({distance})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
+49 -10
View File
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
/// the nearest.
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
return Some(e.clone());
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
return Some(index);
}
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
}
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let pts = edge_points(&m, *index, *point)?;
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
if rim.len() > 1 {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
}
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let len = polyline_len(&pts);
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = face_tris(&m, *index);
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 2 {
if list.is_empty() || list.len() > 1000 {
return Err(bad(cmd, "measure one or two items"));
}
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
let st = s.world_state();
let mut sels = Vec::new();
for value in list {
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
// Two kernel halves of one visible item are one measurement target.
match &mut sel {
Sel::Edge { body, index, point } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
}
}
_ => {}
}
let duplicate = sels.iter().any(|x| match (x, &sel) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => false,
});
if !duplicate {
sels.push(sel);
}
if sels.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
}
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
+3 -1
View File
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name",
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
out["value"] = json!(vals.get(&name).map(|v| v.v));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
}
+19
View File
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
+1 -1
View File
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
+39 -14
View File
@@ -1,7 +1,7 @@
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point.
let st = s.model.state();
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
@@ -406,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
let s1 = (b - a).cross(p - a).dot(n);
let s2 = (c - b).cross(p - b).dot(n);
let s3 = (a - c).cross(p - c).dot(n);
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
// Exact curved boundaries can sit just outside their tessellated chords.
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
})
}
@@ -1332,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
@@ -1409,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
ang = -ang;
std::mem::swap(&mut ka, &mut kb);
}
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
// Two lines make two angles, θ and 180° − θ, whichever way they were
// drawn: the acute one unless the value asks for the other.
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
let supplement =
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
alt = Some(obtuse);
acute
}
}
}
@@ -1426,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = None;
return Ok((String::new(), name, cur));
return Ok((String::new(), name, measured(is_angle, cur)));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
@@ -1437,28 +1451,39 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(err) = errs.get(&pname) {
return Err(bad(cmd, format!("value: {err}")));
}
let _ = vals;
// An angle between two lines dimensions whichever of its two readings is nearer the value.
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
&& (want - ca).abs() < (want - cur).abs()
{
(k, cur) = (ka, ca);
}
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
place_text(sk, &id, text_at);
Ok((pname, name, cur))
Ok((pname, name, measured(is_angle, cur)))
})?;
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
}
/// A dimension's measured value as reported, in the unit its expression is written in.
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
}
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<f64> {
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
solvecraft_sketch::measure_dimension(sk, &sk.constraints.last()?.kind)
let k = &sk.constraints.last()?.kind;
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
+3
View File
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
return Err(bad(cmd, "`width` must be positive"));
}
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let base = if decal { "Decal" } else { "Canvas" };
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
@@ -221,3 +221,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
}
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
/// while the lines meet at 30°.
fn spout() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
assert_eq!(d["sketch"]["dof"], 1, "{d}");
s
}
/// The angle (degrees, 0..=90) between two lines of the active sketch.
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
let sk = sketch(s);
let dir = |l: &str| {
let c = sk.curve_index(l).unwrap();
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
q - p
};
let (u, v) = (dir(a), dir(b));
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
t.min(180.0 - t)
}
#[test]
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
for ents in [["l1", "l3"], ["l3", "l1"]] {
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.0).abs() < 1e-6, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
}
// The other reading still works, and the adjacent top line measures 60° the same way.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 60.0).abs() < 1e-6, "{d}");
// Without a value the acute angle is kept as it is.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
let e = d["expression"].as_str().unwrap();
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
// Lengths report millimetres.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(d["unit"], "mm", "{d}");
}
+14 -2
View File
@@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
made.push(ci);
sk.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
});
return Ok((ids_of(sk, &made), cons));
}
let pool: Vec<usize> = if use_chain {
@@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok((ids_of(sk, &made), cons))
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
+13 -1
View File
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let plane = sketch_plane(doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
let name = if let LinkSource::Face { at, .. } = &r.source {
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
} else {
None
};
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
}
Ok(link)
}
fn link_json(sk: &Sketch, link: &str) -> Value {
+34 -9
View File
@@ -30,6 +30,12 @@ enum K {
Plane,
/// A body name, or a list of them.
Body,
/// A point (or a list of picks) for the sketch being made or edited: into that sketch's
/// component frame, whichever component the geometry under it belongs to (a sketch in the
/// root on a moved component's face finds the face where it is shown).
Here,
/// A plane object for a new sketch or canvas: into the active component's frame.
HerePlane,
}
use K::*;
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
"canvas.decal" => &[("face", Pick)],
"sketch.intersect" => &[("refs", Picks)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
"canvas.decal" => &[("face", Here)],
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)],
"sketch.intersection_curve" => &[("a", Here), ("b", Here)],
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
struct Frames {
bodies: Vec<(String, String, Mat)>,
active: Mat,
/// The frame of the sketch being edited (its component), else the active component's.
sketch: Mat,
}
fn frames(s: &Session) -> Frames {
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
(world, own, m)
})
.collect();
Frames { bodies, active: doc.component_transform(s.active_component) }
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
}
/// Does any placement move anything (so the world differs from the components' frames)?
@@ -171,12 +181,14 @@ struct Mapper<'a> {
/// The frame of the command's first pick (its directions turn with it).
first: Option<Mat>,
changed: bool,
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
here: bool,
}
impl Mapper<'_> {
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
let p = vec3(v)?;
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) };
self.first.get_or_insert(m);
self.local_point(&m, p)
}
@@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
return None;
}
let obj = params.as_object()?;
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
for (k, kind) in keys {
let Some(v) = obj.get(*k) else { continue };
let is_pick = matches!(kind, Pick | Picks | Plane);
let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane);
if is_pick != pass {
continue;
}
@@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
Dir => m.dir(v),
Plane => m.plane(v),
Body => m.body(v),
Here | HerePlane => {
// A new sketch or canvas goes into the active component.
let keep = m.f.sketch;
if *kind == HerePlane {
m.f.sketch = m.f.active;
}
m.here = true;
// Body names stay: the sketch's view of the model keeps them.
let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) };
m.here = false;
m.f.sketch = keep;
r
}
};
if let Some(n) = new {
out.insert((*k).to_string(), n);
+103
View File
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
let c = sketch_point_world(&mut s, "OnTop", "p1");
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
}
/// A 1 x 1 PNG for canvases.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
/// Canvases belong to the active component and move with its occurrence; one put on a moved
/// component's face from the root sits on the face (and stays the root's).
#[test]
fn canvases_move_with_their_component() {
let mut s = moved_part();
let part = s.active_component;
assert_ne!(part, 0);
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"}));
// From the root, on the moved block's top face (world).
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10}));
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"}));
let (vals, _) = s.doc.param_values();
let world = |s: &Session, name: &str| {
let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap();
(c.component, s.doc.canvas_plane(&vals, c).unwrap())
};
let (c, p) = world(&s, "Plan");
assert_eq!(c, part);
assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin);
let (c, p) = world(&s, "Lid");
assert_eq!(c, 0, "the active component's");
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
let centre = p.to_world(lid.center);
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
assert_eq!(world(&s, "Root").0, 0);
// Moving the occurrence carries its canvases, not the root's.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]}));
let (vals, _) = s.doc.param_values();
let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap();
assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0)));
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center);
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap();
assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO));
// The component survives a save and reopen.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.canvases, s.doc.canvases);
}
/// World points of a sketch's curves' points (sorted).
fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec<Vec3> {
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
si["points"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let w = &p["world"];
Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap())
})
.collect()
}
/// A block "C" (40 x 30 x 20) in its own component, the root active again.
fn block_in_component() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "component.activate", json!({"component": "root"}));
s
}
/// The user's "big square": a sketch made in the root on a moved component's face lies on the
/// face where it is shown, its projected loop on the face's edges (moved and turned), and it
/// follows when the occurrence moves on.
#[test]
fn root_sketch_on_a_moved_components_face() {
let mut s = block_in_component();
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"}));
assert!(!r["projected"].is_null(), "the face's edges are projected: {r}");
run(&mut s, "sketch.finish", json!({}));
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.len() >= 4, "{pts:?}");
// Every point but the sketch's origin (the model origin on the face plane).
for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) {
assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}");
}
for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] {
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
}
assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's");
// Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40.
let mut s = block_in_component();
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"}));
run(&mut s, "sketch.finish", json!({}));
let pts = sketch_world_points(&mut s, "Top");
for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] {
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
}
// Projected geometry stays on the face when the occurrence moves on (the link re-resolves).
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]}));
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
}
+60
View File
@@ -170,6 +170,20 @@ pub struct Session {
pub analysis: Option<SurfaceAnalysis>,
/// What is hidden or shown in the view (`browser.visibility`).
pub visibility: Visibility,
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
next_checkpoint: u64,
}
/// The design, active sketch and undo history at an `edit.checkpoint`.
#[derive(Clone, Debug)]
pub(crate) struct Checkpoint {
pub id: u64,
pub doc: Arc<Document>,
pub active_sketch: Option<u64>,
pub undo: Vec<Snapshot>,
pub redo: Vec<Snapshot>,
}
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
@@ -204,6 +218,8 @@ pub enum SurfaceAnalysis {
}
const MAX_UNDO: usize = 200;
/// Checkpoints kept (the oldest go first).
const MAX_CHECKPOINTS: usize = 16;
const MAX_LOG: usize = 2000;
impl Default for Session {
@@ -235,6 +251,8 @@ impl Session {
clipboard: Vec::new(),
section: None,
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
}
}
@@ -356,6 +374,44 @@ impl Session {
}
}
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
pub fn checkpoint(&mut self) -> u64 {
let id = self.next_checkpoint;
self.next_checkpoint += 1;
self.checkpoints.push(Checkpoint {
id,
doc: self.doc.clone(),
active_sketch: self.active_sketch,
undo: self.undo.clone(),
redo: self.redo.clone(),
});
if self.checkpoints.len() > MAX_CHECKPOINTS {
self.checkpoints.remove(0);
}
id
}
/// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It
/// and later checkpoints are used up.
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
self.doc = c.doc;
self.active_sketch = c.active_sketch;
self.undo = c.undo;
self.redo = c.redo;
self.selection.clear();
self.refresh();
Ok(())
}
/// Forget checkpoint `id` (and later ones) without going back.
pub fn drop_checkpoint(&mut self, id: u64) {
if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) {
self.checkpoints.truncate(i);
}
}
/// A scratch copy of the session (same design and model, no history or log) to try
/// commands on without touching this one.
pub fn scratch(&self) -> Session {
@@ -377,6 +433,8 @@ impl Session {
clipboard: self.clipboard.clone(),
section: None,
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
}
}
@@ -445,6 +503,8 @@ impl Session {
}
}
#[cfg(test)]
mod boolean_regression_tests;
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
+62 -2
View File
@@ -20,6 +20,21 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "solid.extrude", json!({"distance": 20}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
let base = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
}
mod component_frames;
mod robustness;
@@ -650,10 +665,10 @@ fn section_analysis() {
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(s.section.is_none());
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
// XZ's normal is +Y: an offset moves the cut to +Y, `at` places it at a world Y, and the
// result says where the cut landed either way.
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(10.0)), "{r}");
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
@@ -824,6 +839,51 @@ fn pipe_scale_offset_bounding_and_materials() {
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
}
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
#[test]
fn deleting_a_path_sketch_deletes_its_pipe() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(s.model.results.iter().all(|r| r.error.is_none()));
}
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
/// which is none once the history is full, leaving their sketches behind.
#[test]
fn checkpoint_restores_with_a_full_undo_history() {
let mut s = Session::default();
for i in 0..MAX_UNDO + 5 {
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
}
assert_eq!(s.undo.len(), MAX_UNDO);
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
let doc = s.doc.clone();
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
assert!(s.active_sketch.is_some());
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
assert!(s.doc.find_feature("TapHole").is_none());
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
// Used up: a second restore is an error, not a jump somewhere else.
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
// Forgetting one leaves the design alone.
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
assert!(s.doc.find_feature("Kept").is_some());
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
}
#[test]
fn components_occurrences_and_world_placement() {
let mut s = Session::default();
+65
View File
@@ -82,6 +82,71 @@ impl Mesh {
pub fn face_edges(&self, f: u32) -> Vec<usize> {
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
}
/// Pieces of one surface joined across hidden seams, including `face` itself.
pub fn surface_patch(&self, face: usize) -> Vec<usize> {
let mut patch = vec![face];
let mut todo = vec![face];
while let Some(f) = todo.pop() {
for (ei, fs) in self.edge_faces.iter().enumerate() {
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
continue;
}
for &next in fs {
let next = next as usize;
if !patch.contains(&next) {
patch.push(next);
todo.push(next);
}
}
}
}
patch.sort_unstable();
patch
}
/// A complete circular rim may be split into several kernel edges. Keep its pieces
/// together for picking, without joining other tangent curves or changing edge indices.
pub fn circular_rim(&self, edge: usize) -> Vec<usize> {
let single = vec![edge];
let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single };
let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single };
let (u, v) = (b - a, c - a);
let n = u.cross(v);
if !(n.len2() > u.len2() * v.len2() * 1e-12) {
return single;
}
let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2());
let radius = center.dist(a);
let normal = n.normalized().unwrap_or(Vec3::ZERO);
let tol = (radius * 1e-6).max(1e-9);
let mut rim = self.tangent_chain(edge, 2f64.to_radians());
if rim.len() < 2 || !radius.is_finite() {
return single;
}
// All samples must lie on this circle, and every end must join exactly one other
// piece. Open arcs, ellipses, and branching tangent chains stay separate.
for &i in &rim {
let Some(points) = self.edges.get(i) else { return single };
if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) {
return single;
}
for end in [points.first(), points.last()].into_iter().flatten() {
let joins = rim
.iter()
.filter(|&&j| j != i)
.filter_map(|&j| self.edges.get(j))
.flat_map(|q| [q.first(), q.last()])
.flatten()
.filter(|q| q.dist(*end) < tol)
.count();
if joins != 1 {
return single;
}
}
}
rim.sort_unstable();
rim
}
/// The edges reached from `e` through vertices where the next edge continues smoothly
/// (angle between the end tangents below `max_angle`), including `e` itself.
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
+2 -2
View File
@@ -17,8 +17,8 @@ pub struct Plane {
impl Plane {
/// Top: sketch x = +X, y = +Y, normal +Z.
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
/// Front: sketch x = +X, y = +Z, normal −Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// Front: sketch x = +X, y = −Z, normal +Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) };
/// Right: sketch x = +Y, y = +Z, normal +X.
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
+42 -2
View File
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
#[test]
fn planes() {
assert_eq!(Plane::XY.normal(), Vec3::Z);
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
assert_eq!(Plane::XZ.normal(), Vec3::Y);
assert_eq!(Plane::YZ.normal(), Vec3::X);
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
@@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() {
assert_eq!(m.face_edges(1), vec![1, 2]);
}
#[test]
fn circular_rims_group_only_closed_cocircular_edges() {
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
(0..=32)
.map(|i| {
let a = start + sweep * i as f64 / 32.0;
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
})
.collect()
};
let pi = std::f64::consts::PI;
let mut m = Mesh {
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
..Default::default()
};
assert_eq!(m.circular_rim(0), vec![0, 1]);
assert_eq!(m.circular_rim(1), vec![0, 1]);
// A hidden seam cannot form a selectable rim.
m.seams = vec![false, true];
assert_eq!(m.circular_rim(0), vec![0]);
m.seams.clear();
m.edges[1] = arc(pi, pi / 2.0, 1.0);
assert_eq!(m.circular_rim(0), vec![0]);
// A closed, smooth ellipse is not a circle.
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
assert_eq!(m.circular_rim(0), vec![0]);
assert_eq!(m.circular_rim(99), vec![99]);
}
#[test]
fn surface_patches_cross_only_hidden_seams() {
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
assert_eq!(m.surface_patch(3), vec![3]);
m.seams.clear();
assert_eq!(m.surface_patch(0), vec![0]);
}
#[test]
fn cubic_and_conic_segments_measure_exactly() {
use crate::{Loop2, Seg2, Vec2};
+56 -1
View File
@@ -795,13 +795,68 @@ pub enum PathSeg {
},
}
impl PathSeg {
/// Unit directions of the path where the segment starts and ends.
fn tangents(&self) -> Option<(Vec3, Vec3)> {
match *self {
PathSeg::Line { a, b } => {
let t = (b - a).normalized()?;
Some((t, t))
}
PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized()?;
let end = rotate_about(a, center, k, angle);
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
}
}
}
/// Points along the segment (an arc within `tol` of its chords), without its start.
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
match *self {
PathSeg::Line { b, .. } => vec![b],
PathSeg::Arc { a, center, axis, angle } => {
let Some(k) = axis.normalized() else { return vec![a] };
let r = (a - center).len();
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
}
}
}
}
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
let v = p - center;
let (s, c) = angle.sin_cos();
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
}
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
/// perpendicular to the path (lines translate it, arcs rotate it).
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners.
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
// Segment by segment the profile only follows a path whose direction runs on across each
// joint: at a corner the next line would slide the profile within its own plane.
let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
if corner {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return crate::sweep_path::sweep_path(plane, region, &pts);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
+8 -2
View File
@@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
/// [`heal`], keeping faces apart along lines they were split along on purpose.
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
// Edges a boolean made are intersection curves, which panic where they no longer
// evaluate; the solid then stays as it is.
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
}
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
shells.push(cur);
}
if !changed {
return solid;
return None;
}
Solid::try_new(shells).unwrap_or(solid)
Solid::try_new(shells).ok()
}
#[cfg(test)]
+2 -1
View File
@@ -150,7 +150,8 @@ fn a_drill_point_round_trips_through_iges() {
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
let profile =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z.
let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
+1 -1
View File
@@ -61,7 +61,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
+41 -11
View File
@@ -15,6 +15,12 @@ pub enum BoolOp {
Intersect,
}
/// Whether a union can sew two matching planar faces without intersecting them.
/// Callers can retain the exact touching tool instead of extending it into material.
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
}
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
/// ray whose direction is derived from point coordinates; moving both solids (and the result
/// back) leaves the geometry unchanged but changes those rays.
@@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
let hi = ba.max.min(bb.max);
let all = ba.union(&bb);
let mut boxes = vec![(all, 300usize)];
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
// A touching seam can overlap by round-off only. Samples there sit on the
// operands' boundaries, where ray parity cannot classify membership reliably.
let gap = all.diagonal().max(1.0) * 1e-9;
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
}
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
@@ -180,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
r
}
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
fn meeting_size(a: &Body, b: &Body) -> f64 {
let bbox = |x: &Body| {
let mut bb = solvecraft_geom::Aabb3::EMPTY;
for v in x.solid.vertex_iter() {
bb.add(from_p3(v.point()));
}
bb
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let size = a.size().max(b.size());
let tol_m = size * 5e-4;
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
// there coarsely enough to fail the membership test on a correct result.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
@@ -287,7 +313,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
empty_votes += 1;
last = "empty result".into();
}
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
Ok(body) => {
let (ok, v) = check(&body);
if ok {
@@ -489,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
/// Split a body with a plane: the parts on the positive and the negative side of the plane
/// (only the non-empty ones).
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
let mut out = Vec::new();
for positive in [true, false] {
out.extend(half_space(body, plane, positive)?);
}
Ok(out)
}
/// The part of a body on one side of a plane (`None` when nothing is there).
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
body.require_brep("split")?;
let size = body.size();
let mut bb = solvecraft_geom::Aabb3::EMPTY;
@@ -506,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
]),
holes: vec![],
};
let mut out = Vec::new();
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
out.push(part);
}
}
Ok(out)
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
boolean(body, &half, BoolOp::Intersect)
}
+106
View File
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
}
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
};
let size = a.size().max(b.size());
polys_boolean(pa, pb, size, op, size * 1e-9)
}
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b)?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
let mesh = a.tessellate(tol).ok()?;
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if infos.get(*fi as usize)?.plane_normal.is_some() {
continue;
}
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
for p in mesh.tri(t)? {
bx.add(p);
}
}
let mut tool = solvecraft_geom::Aabb3::EMPTY;
for p in pb.iter().flat_map(|p| &p.v) {
tool.add(*p);
}
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
let gap = size * 1e-4;
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
let axis = *axes.get(k)?;
[0.5, 0.37, 0.63, 0.25, 0.75]
.iter()
.map(|t| near + (far - near) * t)
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
};
// An axis along which every curved face lies clear of the tool: the cuts either side.
let (k, lo, hi) = (0..3).find_map(|k| {
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
let (mut below, mut above) = (None::<f64>, None::<f64>);
for bx in curved.values() {
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
if c1 < t0 - gap {
below = Some(below.map_or(c1, |x| x.max(c1)));
} else if c0 > t1 + gap {
above = Some(above.map_or(c0, |x| x.min(c0)));
} else {
return None;
}
}
let lo = match below {
Some(c) => Some(place(k, t0, c)?),
None => None,
};
let hi = match above {
Some(c) => Some(place(k, t1, c)?),
None => None,
};
Some((k, lo, hi))
})?;
let (u, w) = match k {
0 => (Vec3::Y, Vec3::Z),
1 => (Vec3::Z, Vec3::X),
_ => (Vec3::X, Vec3::Y),
};
let axis = *axes.get(k)?;
let run = || -> Result<Option<Body>> {
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
let mut middle = a.clone();
let mut outside = Vec::new();
for (cut, keep_positive) in [(lo, true), (hi, false)] {
let Some(x) = cut else { continue };
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
}
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
if op == BoolOp::Intersect {
return Ok(r);
}
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
// they match the pieces outside edge for edge.
let mut pieces: Vec<Body> = match r {
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
None => Vec::new(),
};
// Each piece outside meets the middle along its cut face (glued one shell to another).
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
return Err(fail("a piece in several parts"));
}
for o in outside {
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
if let Some(slot) = pieces.get_mut(i) {
*slot = j;
}
}
crate::ops::join_pieces(pieces)
};
Some(run())
}
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
let polys = csg(pa, pb, op, eps)?;
if polys.is_empty() {
+128 -3
View File
@@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
let mut hi = f64::NEG_INFINITY;
let mut tops: Vec<&mt::Face> = Vec::new();
let mut top_h = Vec::new();
let mut bottom_h = Vec::new();
for f in b.solid.face_iter() {
let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf {
@@ -90,6 +91,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
continue;
}
if n.dot(d) < -1.0 + 1e-9 {
bottom_h.push(h);
lo = lo.min(h);
continue;
}
@@ -110,7 +112,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
}
}
// One height for every top (and bottom) cap.
if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
return None;
}
let mut regions = Vec::new();
@@ -382,6 +384,129 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
}
/// The boolean done on prism sections, or `None` when it doesn't apply.
/// The pieces of a segment between the given points (those lying on it, inside it).
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
return None;
}
let mut ts: Vec<f64> = pts
.iter()
.filter_map(|p| {
let t = seg.closest_param(*p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
})
.collect();
ts.sort_by(f64::total_cmp);
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
let mut out = Vec::new();
let mut t0 = 0.0;
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
_ => return None,
});
t0 = t1;
}
Some(out)
}
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
/// section's outline; each level has a face up over the section below less the one above, and
/// a face down over the one above less the one below. Every outline is split where any other
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
/// line or an arc.
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
let mut slabs: Vec<Vec<Region2>> = Vec::new();
for w in levels.windows(2) {
let (z0, z1) = (*w.first()?, *w.get(1)?);
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
(true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?,
(true, false) => pa.regions.clone(),
(false, true) => pb.regions.clone(),
(false, false) => return None,
};
slabs.push(oriented(&s));
}
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
match (x.is_empty(), y.is_empty()) {
(true, _) => Some(Vec::new()),
(false, true) => Some(x.to_vec()),
(false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)),
}
};
let mut caps: Vec<(f64, bool, Vec<Region2>)> = Vec::new();
for (k, z) in levels.iter().enumerate() {
let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? };
let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]);
caps.push((*z, true, diff(below, above)?));
caps.push((*z, false, diff(above, below)?));
}
// Every segment end, everywhere: outlines split there.
let loops_of =
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
let mut pts: Vec<Vec2> = Vec::new();
for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) {
for sg in &lp.segs {
pts.push(sg.start());
pts.push(sg.end());
}
}
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
match sg {
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
}
};
let mut faces: Vec<FaceSpec> = Vec::new();
for (i, s) in slabs.iter().enumerate() {
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
for lp in loops_of(s) {
for sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? {
let (a, b) = (sg.start(), sg.end());
let lp3 = vec![
edge(&sg, z0),
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
edge(&sg.reversed(), z1),
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
];
let tangent = plane.dir_to_world(sg.derivative(0.5));
let outward = tangent.cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
}
}
}
}
for (z, up, rs) in &caps {
for r in rs {
let mut loops = Vec::new();
for lp in std::iter::once(&r.outer).chain(&r.holes) {
let mut segs: Vec<Seg2> = Vec::new();
for sg in &lp.segs {
segs.extend(split_seg(sg, &pts, tol)?);
}
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
}
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
}
}
Some(crate::sew::sew(&faces))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
@@ -423,10 +548,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
return Some(Ok(None));
}
let (lo, hi) = if stacked {
// Only when the sections are the same (the union adds nothing to either).
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
let (u, x, y) = (area(&regions), area(&pa.regions), area(&pb.regions));
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
return None;
return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some));
}
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
} else {
+1 -1
View File
@@ -1208,7 +1208,7 @@ mod tests {
let m0 = measure(&c).unwrap();
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
let xz = Plane::XZ;
let xz = Plane { y: Vec3::Z, ..Plane::XZ };
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
let s = split_faces(&c, &[side], &tool).unwrap();
let m = measure(&s).unwrap();
+69 -3
View File
@@ -40,8 +40,8 @@ fn extrude_on_other_planes_and_offsets() {
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 250.0) < 1e-9);
// XZ normal is -Y: the body spans y in [-3, 2].
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
// XZ normal is +Y: the body spans y in [-2, 3].
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
}
#[test]
@@ -52,6 +52,39 @@ fn region_with_hole() {
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
}
#[test]
fn through_cut_preserves_a_larger_blind_pocket() {
let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap();
let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap();
let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap();
let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0;
for reverse in [false, true] {
let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) };
let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap();
let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap();
let m = measure(&result).unwrap();
assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume);
assert_eq!(m.merged.faces, 9);
assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0)));
}
}
#[test]
fn join_a_cylinder_on_its_chamfered_top() {
for seam in [0.0, crate::build::SEAM_ANGLE] {
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] };
let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY };
let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap();
let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0;
let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4);
assert_eq!(joined.lumps().unwrap().len(), 1);
}
}
#[test]
fn box_fillet_cut_volume() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
@@ -225,6 +258,38 @@ fn loft_and_sweep() {
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
}
#[test]
fn sweep_round_a_sharp_corner_is_mitred() {
// A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile
// along the second leg within its own plane (an empty, inside-out body). Mitred, the
// volume is the area times the path length.
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] };
let path = [
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) },
];
let b = sweep(&Plane::XY, &sq, &path).unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume);
}
#[test]
fn moving_a_body_with_an_intersection_curve_edge_ends() {
// A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving
// such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled
// forever for about one translation in eight (this one included): the app hung.
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap();
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
@@ -1344,7 +1409,8 @@ fn boolean_identity_survey() {
fn partial_revolve_on_the_axis() {
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0.
let pie = revolve(&plane, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
let v = measure(&pie).unwrap().volume;
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
+29
View File
@@ -213,6 +213,35 @@ fn batch_stops_at_first_error_and_rolls_back() {
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
}
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
#[test]
fn batch_rolls_back_with_a_full_undo_history() {
let mut s = server();
let fill: Vec<Value> =
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
tool(&mut s, "batch", json!({"commands": fill}));
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
for _ in 0..2 {
let r = raw_tool(
&mut s,
"batch",
json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
]}),
);
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["failed"]["index"], 2, "{body}");
assert_eq!(body["undone"], 2, "{body}");
}
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
assert!(d["active_sketch"].is_null(), "{d}");
assert_eq!(d["undo"], undo);
}
#[test]
fn helper_sketches_can_be_hidden() {
let mut s = server();
+55 -12
View File
@@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec<Value> {
"set_parameter",
"Set a parameter",
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.",
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
obj(
json!({
"name": string("Parameter name"),
@@ -360,7 +361,56 @@ fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
}
/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran.
/// How a failed batch goes back to where it started.
enum Undo {
/// An engine checkpoint: the design, active sketch and undo history exactly as they were.
Checkpoint(u64),
/// An app without checkpoints: undo as many steps as the batch added (wrong once the undo
/// history is full, as its depth no longer grows).
Depth(u64),
None,
}
impl Undo {
fn begin(b: &mut dyn Backend) -> Undo {
match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) {
Some(id) => Undo::Checkpoint(id),
None => undo_depth(b).map_or(Undo::None, Undo::Depth),
}
}
/// Go back; the number of steps undone.
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
match self {
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
Ok(_) => steps as u64,
Err(_) => 0,
},
Undo::Depth(before) => {
let after = undo_depth(b).unwrap_or(before);
let mut undone = 0u64;
for _ in before..after {
if exec(b, "edit.undo", json!({})).is_err() {
break;
}
undone += 1;
}
undone
}
Undo::None => 0,
}
}
/// The batch went through: forget the checkpoint.
fn finish(self, b: &mut dyn Backend) {
if let Undo::Checkpoint(id) = self {
let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
}
}
}
/// `batch`: run steps in order, stop at the first failure and (by default) go back to the
/// design as it was before the batch.
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
return text_result(&json!("`commands` must be an array of {command, params}"), true);
@@ -369,7 +419,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
}
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
let before = if rollback { undo_depth(b) } else { None };
let start = if rollback { Undo::begin(b) } else { Undo::None };
let mut results = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
@@ -380,15 +430,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
match r {
Ok(v) => results.push(v),
Err(e) => {
let mut undone = 0u64;
if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) {
for _ in before..after {
if exec(b, "edit.undo", json!({})).is_err() {
break;
}
undone += 1;
}
}
let undone = start.rollback(b, i);
return text_result(
&json!({
"ok": false,
@@ -403,6 +445,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
}
}
start.finish(b);
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
}
+2
View File
@@ -13,6 +13,7 @@ mod dims;
mod linalg;
mod link;
mod model;
mod offset;
mod profiles;
mod shape;
mod solver;
@@ -25,6 +26,7 @@ pub use curves::{
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
+76 -2
View File
@@ -104,6 +104,9 @@ pub struct Link {
#[serde(default)]
pub kind: LinkKind,
pub source: LinkSource,
/// Persistent model face identity, resolved by the document before geometry refresh.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub face_name: Option<String>,
/// The reference could not be found on the last recompute; the geometry is the last good one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lost: bool,
@@ -169,7 +172,7 @@ struct Layout {
curves: Vec<LCurve>,
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
enum LCurve {
Line(usize, usize),
Circle(usize, f64),
@@ -368,7 +371,7 @@ impl Sketch {
return Err(SketchError::TooLarge);
}
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
}
@@ -579,6 +582,77 @@ impl Sketch {
return Ok(false);
}
}
// A resized face can enumerate the same boundary in another order. Match the
// shared-point graph in normalized sketch space before discarding its identity.
if let Some(cur) = &cur
&& points.len() == lay.pts.len()
&& cur.len() == lay.curves.len()
&& points.len() <= 512
{
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
};
let (a, b) = (normalized(&old), normalized(&lay.pts));
let mut mapping = Vec::new();
for p in &a {
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
&& candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5)
&& !mapping.contains(i)
{
mapping.push(*i);
} else {
break;
}
}
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
for c in cur {
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
let found = lay
.curves
.iter()
.enumerate()
.find(|(i, fresh)| {
if used.contains(i) || !same_shape(c, fresh) {
return false;
}
let fresh_pts = curve_points(fresh);
mapped.as_ref().is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
})
.map(|(i, _)| i);
if let Some(i) = found {
used.push(i);
curve_map.push(i);
} else {
break;
}
}
}
if curve_map.len() == cur.len() && mapping.len() == points.len() {
for (pi, li) in points.iter().zip(mapping) {
if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) {
p.pos = *q;
}
}
for (ci, li) in curves.iter().zip(curve_map) {
if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*r = *value;
}
}
return Ok(false);
}
}
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
+7 -8
View File
@@ -467,6 +467,8 @@ pub struct Sketch {
/// Links to geometry outside the sketch (projections, intersections, includes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<crate::link::Link>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offsets: Vec<crate::offset::OffsetChain>,
/// 3D curves (world coordinates) carried by the sketch.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wires: Vec<crate::wire::Wire>,
@@ -495,6 +497,7 @@ impl Sketch {
curves: Vec::new(),
constraints: Vec::new(),
links: Vec::new(),
offsets: Vec::new(),
wires: Vec::new(),
counters: Default::default(),
view: SketchView::default(),
@@ -695,10 +698,8 @@ impl Sketch {
| Symmetric { l, .. }
| PointLineDistance { l, .. }
| LinearDiameter { l, .. } => need_line(l)?,
ArcLength { c, .. } => {
if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) {
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
}
ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => {
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
}
Smooth { a, b } => {
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
@@ -727,10 +728,8 @@ impl Sketch {
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
}
}
Equal { a, b } => {
if is_line(a) != is_line(b) {
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
}
Equal { a, b } if is_line(a) != is_line(b) => {
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
}
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
_ => {}
+116
View File
@@ -0,0 +1,116 @@
//! Associative offsets keep their output entity ids while their source curves move.
use crate::{CurveKind, Shape, Sketch, SketchError, intersections};
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec2;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OffsetChain {
pub sources: Vec<(String, bool)>,
pub outputs: Vec<String>,
pub distance: f64,
pub closed: bool,
}
impl Sketch {
pub fn refresh_offsets(&mut self) -> Result<(), SketchError> {
for offset in self.offsets.clone() {
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
return Err(SketchError::Invalid("damaged offset chain".into()));
}
let bad = || SketchError::Invalid("an offset source vanished or changed shape".into());
let mut pieces = Vec::new();
for (id, forward) in &offset.sources {
let i = self.curve_index(id).ok_or_else(bad)?;
let kind = &self.curves.get(i).ok_or_else(bad)?.kind;
let piece = match *kind {
CurveKind::Line { a, b } => {
let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
let (a, b) = if *forward { (a, b) } else { (b, a) };
let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance;
(Shape::Line { a: a + n, b: b + n }, a + n, b + n)
}
CurveKind::Arc { c, a, b } => {
let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
let r = c.dist(a) - if *forward { offset.distance } else { -offset.distance };
if r <= 1e-9 {
return Err(SketchError::Invalid("the offset collapses an arc".into()));
}
let a = c + (a - c).normalized().ok_or_else(bad)? * r;
let b = c + (b - c).normalized().ok_or_else(bad)? * r;
let (a, b) = if *forward { (a, b) } else { (b, a) };
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b)
}
CurveKind::Circle { c, r } => {
let c = self.point(c).ok_or_else(bad)?;
let r = r - offset.distance;
if r <= 1e-9 {
return Err(SketchError::Invalid("the offset collapses a circle".into()));
}
let out = offset.outputs.first().and_then(|id| self.curve_index(id)).ok_or_else(bad)?;
if let Some(crate::Curve { kind: CurveKind::Circle { r: target, .. }, .. }) = self.curves.get_mut(out) {
*target = r;
} else {
return Err(bad());
}
let _ = c;
continue;
}
_ => return Err(bad()),
};
pieces.push(piece);
}
if pieces.is_empty() {
continue;
}
let joint = |i: usize, j: usize| -> Result<Vec2, SketchError> {
let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?);
if x.2.dist(y.1) < 1e-6 {
return Ok(x.2);
}
let guess = (x.2 + y.1) * 0.5;
intersections(&x.0, &y.0)
.into_iter()
.min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)))
.ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into()))
};
let n = pieces.len();
let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }];
for i in 0..n.saturating_sub(1) {
joints.push(joint(i, i + 1)?);
}
if !offset.closed {
joints.push(pieces.last().ok_or_else(bad)?.2);
}
for (i, id) in offset.outputs.iter().enumerate() {
let a = *joints.get(i).ok_or_else(bad)?;
let b = *joints.get(if offset.closed { (i + 1) % n } else { i + 1 }).ok_or_else(bad)?;
let shape = &pieces.get(i).ok_or_else(bad)?.0;
if let Shape::Line { a: p, b: q } = shape
&& (b - a).dot(*q - *p) <= 1e-9
{
return Err(SketchError::Invalid("the offset curves would cross".into()));
}
let ci = self.curve_index(id).ok_or_else(bad)?;
let kind = self.curves.get(ci).ok_or_else(bad)?.kind.clone();
let (pa, pb) = match kind {
CurveKind::Line { a, b } => (a, b),
CurveKind::Arc { a, b, .. } => {
if offset.sources.get(i).is_some_and(|x| x.1) {
(a, b)
} else {
(b, a)
}
}
_ => return Err(bad()),
};
for (pi, pos) in [(pa, a), (pb, b)] {
if let Some(p) = self.points.get_mut(pi) {
p.pos = pos;
p.fixed = true;
}
}
}
}
Ok(())
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
if text.chars().count() > MAX_TEXT_CHARS {
return Err(SketchError::TooLarge);
}
if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() {
if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) {
return Err(SketchError::Invalid("text height must be positive".into()));
}
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
+1 -1
View File
@@ -98,7 +98,7 @@ impl Sketch {
}
let id = if geom.is_empty() {
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
id
} else {
self.add_link(kind, source, geom)?
+25 -25
View File
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
/// One row's look.
#[derive(Default)]
struct Row<'a> {
depth: usize,
pub(crate) struct Row<'a> {
pub(crate) depth: usize,
/// Some(open) for rows that fold.
fold: Option<bool>,
pub(crate) fold: Option<bool>,
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: &'a str,
label: &'a str,
selected: bool,
dim: bool,
color: Option<Color32>,
pub(crate) eye: Option<bool>,
pub(crate) icon: &'a str,
pub(crate) label: &'a str,
pub(crate) selected: bool,
pub(crate) dim: bool,
pub(crate) color: Option<Color32>,
/// Component rows: is it the active one?
radio: Option<bool>,
pub(crate) radio: Option<bool>,
/// Trailing badges: "lock", "pin".
badges: &'a [&'a str],
pub(crate) badges: &'a [&'a str],
}
/// What happened on a row.
#[derive(Default)]
struct RowResp {
rect: Option<Rect>,
clicked: bool,
double: bool,
secondary: Option<Pos2>,
fold: bool,
eye: bool,
radio: bool,
hovered: bool,
pub(crate) struct RowResp {
pub(crate) rect: Option<Rect>,
pub(crate) clicked: bool,
pub(crate) double: bool,
pub(crate) secondary: Option<Pos2>,
pub(crate) fold: bool,
pub(crate) eye: bool,
pub(crate) radio: bool,
pub(crate) hovered: bool,
/// The row's response (drag and drop).
resp: Option<egui::Response>,
pub(crate) resp: Option<egui::Response>,
/// The press began on the fold arrow, eye or radio (not a drag of the row).
on_control: bool,
pub(crate) on_control: bool,
}
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
let t = Tokens::get();
// Rows scrolled out of view only take their space: no widgets, no painting.
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
None
}
fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
}
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
if default {
if !app.tree.collapsed.remove(key) {
app.tree.collapsed.insert(key.to_string());
+13 -5
View File
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
vec![
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed),
act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })),
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
Item::sep(),
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
@@ -792,12 +795,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
crate::dialogs_assembly::edit_joint(app, id)
} else {
crate::dialogs_assembly::drive_joint(app, id)
crate::dialogs_assembly::find_joint(app, id);
}
}
"ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = match item.id.as_str() {
"ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id),
"ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id),
_ => crate::dialogs_assembly::drive_joint(app, id),
};
if let Some(d) = d {
app.tool = None;
+57 -8
View File
@@ -480,10 +480,12 @@ impl Dialog {
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![sel.clone()]),
(Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)],
_ if inp.multi => crate::viewport::grouped_selection(s, sel),
_ => vec![sel.clone()],
};
for x in picked {
if !inp.items.contains(&x) {
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -631,8 +633,11 @@ impl Dialog {
/// A pick in the viewport (already accepted by the active input).
pub fn pick(&mut self, s: &Session, sel: Sel) {
let measure = matches!(self.kind, Kind::Measure { .. });
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
let mut picked = vec![sel.clone()];
let mut picked =
if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] };
// Tangent chain: an edge brings the edges that continue it smoothly.
if chain
&& let Sel::Edge { body, index, .. } = &sel
@@ -667,13 +672,15 @@ impl Dialog {
}
/// Box selection result: replace (or add to) the active input.
pub fn take_box(&mut self, sels: Vec<Sel>, add: bool) {
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
let measure = matches!(self.kind, Kind::Measure { .. });
let Some(inp) = self.inputs.get_mut(self.active) else { return };
if !add {
inp.items.clear();
}
for x in sels {
if fits(inp.accept, &x) && !inp.items.contains(&x) {
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -1848,7 +1855,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Err(e) => d.error = Some(e),
}
}
if !(keep && !cancel) {
if !keep || cancel {
crate::dialogs_motion::closed(app, &mut d, applied);
}
if keep && !cancel {
@@ -2691,11 +2698,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
}
}
thread_local! {
/// A dialog is being filled from a feature: its points are in component frames.
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// While filling a dialog from a feature (dropped at the end).
struct Refill;
impl Refill {
fn start() -> Refill {
REFILL.with(|r| r.set(true));
Refill
}
}
impl Drop for Refill {
fn drop(&mut self) {
REFILL.with(|r| r.set(false));
}
}
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
/// the frame of the body they were on, so each body's placement takes the point to the world
/// (a moved component, a pasted instance). Other points are the world already.
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
}
/// Body placements while filling a dialog from a feature (when anything is placed).
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
return None;
}
let st = s.model.state();
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
}
/// The edge of a visible body through (or nearest) a point.
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (i, e) in m.edges.iter().enumerate() {
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
@@ -2708,10 +2754,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
/// The body face containing a point (nearest triangle).
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
@@ -2721,7 +2769,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
}
}
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
}
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
@@ -2756,6 +2804,7 @@ fn pt3(v: Vec3) -> Value {
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
/// before the feature, so its references show on the geometry they refer to.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
let _refill = Refill::start();
let s = &app.session;
let f = s.doc.feature(id)?.clone();
let st = s.world_state();
+165 -60
View File
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
}
/// The Drive Joints dialog on a joint (by id).
/// Edit Joint with its motion playing (the browser's Animate Joint).
pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let mut d = edit_joint(app, id)?;
if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind {
f.animate = true;
}
Some(d)
}
/// Frame the two components a joint connects (the browser's Find in Window).
pub fn find_joint(app: &mut SolveApp, id: u64) {
let s = &app.session;
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return };
let occs = [j.a.occurrence, j.b.occurrence];
let model = s.model.state();
let world = s.world_state();
let mut bb = Aabb3::default();
for (name, _, path, _) in s.doc.body_frames(&model) {
// The root (occurrence 0) holds the bodies outside every occurrence.
let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) });
if on && let Some(b) = world.body(&name) {
bb = bb.union(&b.mesh().bounds());
}
}
if bb.is_empty() {
return;
}
let mut cam = app.cam;
cam.fit(&bb);
app.animate_to(cam);
}
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
@@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
row_label(ui, "");
ui.spacing_mut().slider_width = 124.0;
ui.spacing_mut().slider_width = 160.0;
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
if r.changed() {
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
}
ui.end_row();
// The slider's range: the joint's limits, or the default span when it has none.
let limited = j.limits.get(i).copied().flatten().is_some();
let fmt = |x: f64| {
let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") };
if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") }
};
row_label(ui, "");
ui.label(
RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" }))
.size(11.0)
.color(if limited { t.text } else { t.text_dim }),
);
ui.end_row();
}
}
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.hover),
Asm::JointOrigin { hover, .. } => Some(hover),
@@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
.filter(|(_, i)| i.accept == SNAPS)
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
.collect();
for (s, c) in &picked {
glyph(painter, proj, s, *c, app.cam.half_height());
for (n, (s, c)) in picked.iter().enumerate() {
let tag = format!("Component {}", n + 1);
glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str()));
}
// The snap under the cursor (found again only when the hovered item changes).
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
@@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
}
if let Some((_, Some(s))) = hover.as_ref() {
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height());
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind)));
}
}
None => *hover = None,
@@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
/// A snap: its kind's marker at the point and the z axis as an arrow.
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) {
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
let Some(c) = proj.to_screen(s.at) else { return };
let len = half_height * 2.0 * 0.06;
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE));
let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
painter.line_segment([c, tip], halo);
painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
painter.line_segment([c, tip], Stroke::new(2.0, col));
painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
}
let white = Stroke::new(1.5, Color32::WHITE);
let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
match s.kind {
SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
SnapKind::EdgeMid => {
let r = 8.0 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
}
SnapKind::Vertex => {
let r = 7.5 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match s.kind {
SnapKind::FaceCenter => {
painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle);
painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
}
SnapKind::CircleCenter => {
painter.circle(c, 6.0, col, white);
painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
}
SnapKind::EdgeMid => {
painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white));
}
SnapKind::Vertex => {
painter.add(egui::Shape::convex_polygon(
vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)],
col,
white,
));
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
}
}
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(11.5);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
painter.galley(at, g, t.text);
}
}
/// What a snap is called (the hover label).
fn snap_name(k: SnapKind) -> &'static str {
match k {
SnapKind::FaceCenter => "Face center",
SnapKind::CircleCenter => "Circle center",
SnapKind::EdgeMid => "Edge midpoint",
SnapKind::Vertex => "Vertex",
}
}
// ---- timeline and browser ----
@@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
/// edits, drives, suppresses or deletes).
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
let t = Tokens::get();
use crate::browser::{Row, draw_row, is_open, toggle};
let joints: Vec<(u64, String, JointKind, bool)> =
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
if joints.is_empty() {
return;
}
let fold_id = egui::Id::new("sc_browser_joints_open");
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let x = r.left() + 2.0 + depth * 14.0;
let cy = r.center().y;
if let Some(o) = fold {
let c = egui::pos2(x + 6.0, cy);
let pts = if o {
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
} else {
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
}
let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0));
crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent);
ui.painter().text(
egui::pos2(x + 38.0, cy),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
if dim { t.text_dim } else { t.text },
);
resp
};
let depth = depth as f32;
if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(fold_id, open));
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
let key = "c0/joints";
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
if r.fold || r.clicked {
toggle(app, key, true);
}
if !open {
return;
}
let mut dialog: Option<Dialog> = None;
for (id, name, kind, suppressed) in joints {
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
if resp.double_clicked() {
let r =
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
if let Some(resp) = &r.resp {
resp.clone().on_hover_text(format!("{kind:?} joint"));
}
if r.double {
dialog = edit_joint(app, id);
}
// The same menus as the rest of the browser.
if resp.secondary_clicked() {
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
if let Some(at) = r.secondary {
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
}
let _ = (kind, suppressed);
}
if let Some(d) = dialog {
app.tool = None;
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
#[cfg(test)]
#[path = "dialogs_assembly_tests.rs"]
mod tests;
thread_local! {
/// Named joint origins in the world, for the design state they were found in.
static ORIGINS: std::cell::RefCell<Option<(String, Vec<(String, solvecraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
}
/// Named joint origins in the world: on their occurrence's component, so they move with it
/// (joint drives, free moves not captured yet, Capture Position).
pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> {
if s.doc.assembly.origins.is_empty() {
return Vec::new();
}
let key = format!("{}:{:?}", s.revision, s.pending_moves);
if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
return v;
}
let doc: std::borrow::Cow<solvecraft_engine::doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
let st = s.model.state();
let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect();
ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone())));
v
}
/// Named joint origins in the viewport: a small triad where each sits.
pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let len = app.cam.half_height() * 2.0 * 0.04;
for (_, m) in origins_world(&app.session) {
let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO);
let Some(c) = proj.to_screen(at) else { continue };
for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] {
if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
}
}
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
}
}
@@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() {
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
assert!(app.dialog.is_none());
}
/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free
/// move not captured yet, carries them along.
#[test]
fn joint_origins_follow_their_component() {
let mut app = two_boxes();
app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap();
let at = |app: &SolveApp| {
let v = origins_world(&app.session);
let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap();
solvecraft_engine::doc::apply_point(m, Vec3::ZERO)
};
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app));
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
// A drag not captured yet.
let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform;
m[3][0] += 5.0;
app.session.pending_moves.insert(b, m);
assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
}
/// Edit Feature on a moved component: the dialog shows the feature's picks where the
/// component is (its stored points are in the component's frame).
#[test]
fn edit_feature_on_a_moved_component_refills_in_the_world() {
let mut app = two_boxes();
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap();
let id = r["feature"].as_u64().unwrap();
let d = crate::dialogs::for_feature(&app, id, None).unwrap();
let items: Vec<Sel> = d.inputs.iter().flat_map(|i| i.items.clone()).collect();
let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") };
assert_eq!(body, "Body2");
assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}");
}
+127 -8
View File
@@ -41,6 +41,10 @@ pub enum Mo {
tracks: Vec<Track>,
step: f64,
playing: bool,
/// Playback starts over at the end (else it stops there).
looping: bool,
/// Playback speed (index into `SPEEDS`).
speed: usize,
/// The step the shown pose is for (and its revision).
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
tracks: Vec::new(),
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved: None,
error: None,
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
// ---- pure mappings (tested) ----
/// Playback speeds offered (times `STEPS_PER_S`).
pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
/// Steps played per second at 1×.
const STEPS_PER_S: f64 = 24.0;
/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there.
/// Returns the step and whether it still plays.
pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) {
let last = f64::from(total);
let next = step + by.max(0.0);
if next <= last {
(next, true)
} else if looping && last > 0.0 {
(next % (last + 1.0), true)
} else {
(last, false)
}
}
#[derive(Clone, Copy)]
enum Transport {
Start,
Back,
Play,
Stop,
Forward,
End,
}
/// A media button drawn in code: bars and triangles.
fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click());
let fill = if resp.hovered() { t.hover } else { t.field };
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let c = r.center();
let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon };
let p = ui.painter();
let tri = |dir: f32, x: f32| {
egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE)
};
let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink);
match what {
Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))),
Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)),
Transport::Back => {
p.add(bar(c.x + 4.0));
p.add(tri(-1.0, c.x - 1.0));
}
Transport::Forward => {
p.add(bar(c.x - 4.0));
p.add(tri(1.0, c.x + 1.0));
}
Transport::Start => {
p.add(bar(c.x - 6.0));
p.add(tri(-1.0, c.x - 0.5));
p.add(tri(-1.0, c.x + 5.0));
}
Transport::End => {
p.add(tri(1.0, c.x - 5.0));
p.add(tri(1.0, c.x + 0.5));
p.add(bar(c.x + 6.0));
}
}
resp.on_hover_text(tip)
}
/// The study command's parameters.
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
}
}
Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => {
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
row_label(ui, "Name");
ui.text_edit_singleline(name);
ui.end_row();
@@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
return enter;
}
// Playback.
row_label(ui, "Step");
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
row_label(ui, "Playback");
ui.horizontal(|ui| {
if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() {
let last = f64::from(total);
if transport(ui, Transport::Start, "To the start").clicked() {
*step = 0.0;
*playing = false;
}
if transport(ui, Transport::Back, "Step back").clicked() {
*step = (step.round() - 1.0).max(0.0);
*playing = false;
}
let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") };
if transport(ui, what, tip).clicked() {
// Play from the start again when at the end.
if !*playing && step.round() >= last {
*step = 0.0;
}
*playing = !*playing;
}
ui.spacing_mut().slider_width = 220.0;
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
if transport(ui, Transport::Forward, "Step on").clicked() {
*step = (step.round() + 1.0).min(last);
*playing = false;
}
if transport(ui, Transport::End, "To the end").clicked() {
*step = last;
*playing = false;
}
ui.add_space(6.0);
ui.checkbox(looping, "Loop");
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
combo(ui, "ms_speed", &refs, speed);
});
ui.end_row();
row_label(ui, "Step");
ui.horizontal(|ui| {
ui.spacing_mut().slider_width = 260.0;
if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() {
*playing = false;
}
ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim));
});
ui.end_row();
if *playing {
let dt = ui.input(|i| i.stable_dt).min(0.1);
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0);
(*step, *playing) = advance(*step, dt * fps, total, *looping);
ui.ctx().request_repaint();
}
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
@@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
mod tests {
use super::*;
/// Playback runs on, starts over at the end when looping, else stops on the last step.
#[test]
fn playback_advances_loops_and_stops() {
assert_eq!(advance(3.0, 2.0, 10, true), (5.0, true));
assert_eq!(advance(9.5, 2.0, 10, true), (0.5, true));
assert_eq!(advance(9.5, 2.0, 10, false), (10.0, false));
assert_eq!(advance(4.0, -3.0, 10, false), (4.0, true));
}
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
#[test]
fn shown_poses_are_put_back() {
@@ -688,6 +805,8 @@ mod tests {
tracks: vec![],
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved,
error: None,
+1 -1
View File
@@ -187,7 +187,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
}
let tk = Tokens::get();
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
let font = FontId::proportional(12.0);
let centres = chain_centres(sk);
+22 -2
View File
@@ -98,6 +98,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
/// Make tab `i` the active design.
pub fn switch(app: &mut SolveApp, i: usize) {
app.home.open = false;
if i == app.docs.active || i >= app.docs.slots.len() {
return;
}
@@ -293,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
/// + for a new design.
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let t = Tokens::get();
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
ui.painter().rect_filled(
home,
5.0,
if app.home.open {
t.toolbar
} else if response.hovered() {
t.hover
} else {
Color32::from_white_alpha(10)
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() {
app.home.show(app.session.revision);
}
let x0 = x0 + 70.0;
let info = tabs_info(app);
let n = info.len();
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
@@ -301,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let mut shut: Option<usize> = None;
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
let active = i == app.docs.active || app.docs.slots.is_empty();
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
// The tabs are part of the title bar: dragging one moves the window, a double click
// maximizes it.
if app.custom_titlebar || app.integrated_titlebar {
@@ -389,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
new_design(app);
app.home.show(app.session.revision);
}
if let Some(i) = shut {
close(app, i, false);
+1 -1
View File
@@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
}
let state: Option<State> = ui.data(|d| d.get_temp(id));
let pointer = ui.input(|i| i.pointer.hover_pos());
let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None };
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
let active = state.map(|s| s.handle).or(hovered);
let hot = |h: Handle| active == Some(h);
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
+53 -19
View File
@@ -1,6 +1,6 @@
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
//! picture of the design rendered from it. Shown on launch without a file and from the house
//! button; it closes when a design is opened or started, or the design changes.
//! button or the first document tab; selecting a design returns to its view.
use std::collections::HashMap;
@@ -24,8 +24,7 @@ pub struct HomeState {
pub open: bool,
/// Recent design paths, newest first (kept in the preferences).
pub recent: Vec<String>,
/// The session revision when the page opened (a change closes it).
rev: u64,
pub opened_at: std::collections::BTreeMap<String, u64>,
thumbs: HashMap<String, Option<egui::TextureHandle>>,
samples: HashMap<usize, Session>,
/// Keys asked for and not made yet.
@@ -37,9 +36,8 @@ pub struct HomeState {
}
impl HomeState {
pub fn show(&mut self, rev: u64) {
pub fn show(&mut self, _rev: u64) {
self.open = true;
self.rev = rev;
}
/// Put a design at the top of the recent list.
@@ -47,6 +45,8 @@ impl HomeState {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_string());
self.recent.truncate(MAX_RECENT);
self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
self.opened_at.retain(|p, _| self.recent.contains(p));
self.thumbs.remove(path);
}
}
@@ -179,9 +179,8 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
}
fn open_recent(app: &mut SolveApp, path: &str) {
pub fn open_recent(app: &mut SolveApp, path: &str) {
if !solvecraft_engine::io::vfs::exists(path) {
app.home.recent.retain(|p| p != path);
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) {
/// The page itself (in place of the toolbar, browser and viewport).
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.session.revision != app.home.rev {
app.home.open = false;
return;
}
poll(app, ui.ctx());
let t = Tokens::get();
let samples = solvecraft_engine::sample::samples();
@@ -261,6 +256,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
ui.add_space(24.0);
ui.horizontal_wrapped(|ui| {
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new");
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest");
if ui.link("Keyboard Shortcuts").clicked() {
app.keymap.open = true;
app.keymap.read_only = true;
}
if ui.link("Preferences").clicked() {
crate::prefs::open_at(app, 0);
}
});
ui.add_space(16.0);
section(ui, "Recent");
if recent.is_empty() {
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
@@ -269,20 +277,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(p) = recent.get(i) else { return };
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let missing = !solvecraft_engine::io::vfs::exists(p);
let sub = if missing {
let mut sub = if missing {
"missing".to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
"in this browser".to_string()
} else {
folder
p.clone()
};
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str())
.clicked()
{
if let Some(time) = app.home.opened_at.get(p) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
let seconds = now.saturating_sub(*time);
let ago = if seconds < 60 {
"just now".to_string()
} else if seconds < 3600 {
format!("{} min ago", seconds / 60)
} else if seconds < 86400 {
format!("{} h ago", seconds / 3600)
} else {
format!("{} days ago", seconds / 86400)
};
sub.push_str(&format!("\nOpened {ago}"));
}
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str());
crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center());
response.context_menu(|ui| {
if ui.button("Remove from list").clicked() {
app.home.recent.retain(|x| x != p);
app.home.opened_at.remove(p);
ui.close();
}
if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() {
if let Some(show) = &app.services.show_in_folder {
show(p);
}
ui.close();
}
});
if response.clicked() {
let p = p.clone();
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
}
@@ -340,7 +374,7 @@ enum Pic<'a> {
/// A design card: its picture (a spinner while it renders), name and a line under it.
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
let t = Tokens::get();
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let hover = resp.hovered();
ui.painter().rect(
+23 -1
View File
@@ -155,6 +155,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
pub graphics_path: Option<String>,
}
@@ -304,6 +305,7 @@ impl SolveApp {
"browser_collapsed": self.tree.collapsed,
"browser_expanded": self.tree.expanded,
"recent": self.home.recent,
"recent_opened_at": self.home.opened_at,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
@@ -319,6 +321,7 @@ impl SolveApp {
pub fn load_prefs(&mut self, prefs: &str) {
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default();
if let Some(d) = flag("dark") {
self.ui.dark = d;
}
@@ -430,6 +433,12 @@ impl SolveApp {
}
self.tool = None;
self.home.open = false;
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
let _ = self.run(id, json!({"path": path}));
}
return;
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
@@ -480,7 +489,20 @@ impl SolveApp {
{
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
}
if let Some(t) = tools::Tool::for_command(id) {
if let Some(mut t) = tools::Tool::for_command(id) {
if id == "sketch.dimension" {
t.picks = self
.session
.selection
.iter()
.filter_map(|s| match s {
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.take(2)
.collect();
let _ = self.session.execute("select.clear", &json!({}));
}
self.tool = Some(t);
self.set_status(tools::hint(id), false);
return;
+4 -2
View File
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
let b0 = app.session.model.state().bodies[0].name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let body = &app.session.model.state().bodies[0];
let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap();
let b0 = body.name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap();
crate::delete::delete_selection(&mut app);
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
assert_eq!(app.session.doc.features.len(), n);
+2 -2
View File
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
.iter()
.filter(|c| c.visible && c.opacity > 0.0)
.filter_map(|c| {
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
let plane = doc.canvas_plane(&vals, c).ok()?;
let (_, version, size, rgba) = pixels(c)?;
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
}
let (vals, _) = doc.param_values();
for c in doc.canvases.iter().filter(|c| c.visible) {
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
let Ok(plane) = doc.canvas_plane(&vals, c) else { continue };
let Some(tex) = texture(ui.ctx(), c) else { continue };
let [bl, br, _, tl] = c.corners();
let (u, v) = (br - bl, tl - bl);
+9
View File
@@ -245,6 +245,15 @@ impl Harness {
}
pub fn call(&mut self, method: &str, params: Value) -> Value {
if method == "ui.resize"
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
&& w.is_finite()
&& h.is_finite()
&& w > 0.0
&& h > 0.0
{
self.size = egui::vec2(w as f32, h as f32);
}
let (req, _rx) = ControlRequest::new(method, params);
let out = match handle(&mut self.app, &self.ctx, &req) {
Outcome::Done(v) => v,
+12 -3
View File
@@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3;
use crate::viewport::Hit;
/// Model picks retain an on-surface point, but their identity is the body and topology index.
pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool {
match (a, b) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => a == b,
}
}
/// What a selection input accepts (bit set).
pub type Accept = u16;
pub const PROFILES: Accept = 1;
@@ -73,11 +82,11 @@ impl SelInput {
return;
}
// A pick of something already in (or a chain fully in) takes it out.
if picked.iter().all(|p| self.items.contains(p)) {
self.items.retain(|x| !picked.contains(x));
if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) {
self.items.retain(|x| !picked.iter().any(|p| same_item(x, p)));
} else {
for p in picked {
if !self.items.contains(&p) {
if !self.items.iter().any(|x| same_item(x, &p)) {
self.items.push(p);
}
}
+1 -1
View File
@@ -1090,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
snap_hint(app, painter, proj);
combs(app, painter, proj);
if DEFERRED.with(|d| d.replace(false)) {
+53 -1
View File
@@ -54,6 +54,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
x += 30.0;
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
@@ -148,8 +149,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
crate::documents::new_design(app);
@@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
close = true;
}
ui.menu_button("Open Recent", |ui| {
for path in app.home.recent.clone() {
let missing = !solvecraft_engine::io::vfs::exists(&path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if ui.button(label).clicked() {
crate::home::open_recent(app, &path);
close = true;
ui.close();
}
}
ui.separator();
if ui.button("Clear Recent").clicked() {
app.home.recent.clear();
app.home.opened_at.clear();
close = true;
ui.close();
}
});
if item(ui, "Insert STEP…", "") {
app.start("file.insert_step");
close = true;
@@ -308,11 +330,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = r.top() + 26.0;
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
let mut overflow = Vec::new();
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
if !overflow.is_empty() || px + width > r.right() - 40.0 {
overflow.push((*panel, cmds));
continue;
}
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
@@ -433,6 +460,31 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
px += 2.0;
}
if !overflow.is_empty() {
let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| {
let response = ui.menu_button("»", |ui| {
for (panel, cmds) in &overflow {
ui.menu_button(*panel, |ui| {
if *panel == "SELECT" {
selection_filter(app, ui);
}
for c in cmds {
let info = c.info(&app.session);
let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center());
if response.clicked() {
app.start(c.id);
ui.close();
}
}
});
}
});
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
response.response.on_hover_text("More toolbar panels");
});
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
+13 -23
View File
@@ -80,7 +80,7 @@ pub fn hint(id: &str) -> String {
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
"sketch.dimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
@@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
Kind::Dimension => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find_map(|h| match h {
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
_ => None,
});
if let Some((id, is_point)) = hit {
tool.picks.push(id.clone());
let st = app.session.world_state();
let is_line = app
.session
.active_sketch
.and_then(|s| st.sketch(s))
.and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned()))
.map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }));
let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2;
let single_line = tool.picks.len() == 1 && is_line == Some(true);
if ready || single_line {
// A single line gets its length right away; a second pick (line or point) replaces it.
if let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks}))
&& let Some(p) = v["param"].as_str()
{
// The value is edited in place, selected so typing replaces it.
crate::dim_view::edit_param(app, p);
}
tool.picks.clear();
if let Some(id) = hit {
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
tool.picks.push(id);
}
} else if !tool.picks.is_empty()
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
&& let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]}))
{
if let Some(p) = v["param"].as_str() {
crate::dim_view::edit_param(app, p);
}
tool.picks.clear();
}
}
}
+369 -19
View File
@@ -1091,7 +1091,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let t = Tokens::get();
let mut sc = GpuScene::default();
let st = app.session.world_state();
let sel = app.highlighted();
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in grouped_selection(&app.session, &item) {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
}
}
let hover = app.viewport.hover.as_ref();
let size = origin_size(app.cam.half_height());
// Origin planes and construction planes.
@@ -1242,9 +1249,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
if let Some(b) = st.body(body) {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
let m = b.mesh();
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
let patch = m.surface_patch(*index);
for &f in &patch {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
&& !m.seams.get(e).copied().unwrap_or(false)
{
@@ -1254,12 +1264,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
}
Some(Hit::Edge { body, index, mid }) => {
if let Some(b) = st.body(body)
&& let Some(e) = edge_of(&b.mesh(), *index, *mid)
{
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9));
if !selected {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
if let Some(b) = st.body(body) {
let m = b.mesh();
for i in m.circular_rim(*index) {
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i));
if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
}
}
}
}
@@ -1286,7 +1297,7 @@ fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) {
(c.r(), c.g(), c.b())
}
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 {
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in pts.windows(2) {
@@ -1360,7 +1371,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The view cube handles its own clicks; the model underneath must not see them.
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p));
// Floating windows and menus own input over the model, including wheel navigation.
let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
// Ctrl or Alt held: drawing and dragging don't snap.
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
@@ -1682,6 +1694,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.boxsel = None;
}
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch3d::show(app, &painter, &proj);
@@ -1729,12 +1742,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
match sel {
Some(x) => {
let item = serde_json::to_value(&x).unwrap_or_default();
if add && app.session.selection.contains(&x) {
let rest: Vec<Sel> = app.session.selection.iter().filter(|y| **y != x).cloned().collect();
let items = grouped_selection(&app.session, &x);
if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) {
let rest: Vec<Sel> =
app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect();
let _ = app.run("select.set", json!({ "items": rest }));
} else {
let _ = app.run("select.set", json!({"items": [item], "add": add}));
let _ = app.run("select.set", json!({"items": items, "add": add}));
}
}
None if !add => {
@@ -1744,6 +1758,75 @@ fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
}
}
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
/// face pick keeps its point (used by tools such as Hole and construction geometry).
pub(crate) fn grouped_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Vec<Sel> {
let st = s.world_state();
match sel {
Sel::Edge { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
return m
.circular_rim(*index)
.into_iter()
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
.collect();
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let mut patch = m.surface_patch(*index);
patch.sort_by_key(|i| usize::from(i != index));
return patch
.into_iter()
.filter_map(|i| {
if i == *index {
return Some(sel.clone());
}
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != i {
return None;
}
let [a, b, c] = m.tri(t)?;
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
})
})
.collect();
}
}
_ => {}
}
vec![sel.clone()]
}
/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves.
pub(crate) fn measurement_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Sel {
let pieces = grouped_selection(s, sel);
let mut representative = sel.clone();
match &mut representative {
Sel::Edge { index, .. } => {
if let Some(Sel::Edge { index: i, .. }) = pieces.first() {
*index = *i;
}
}
Sel::Face { index, .. } => {
if let Some(i) = pieces
.iter()
.filter_map(|p| match p {
Sel::Face { index, .. } => Some(*index),
_ => None,
})
.min()
{
*index = i;
}
}
_ => {}
}
representative
}
/// What a box selects: sketch entities while sketching, otherwise what the open dialog's
/// active input takes (edges, faces or bodies), otherwise bodies.
fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
@@ -1793,7 +1876,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
let m = b.mesh();
if accept & EDGES != 0 {
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) {
let rim = m.circular_rim(ei);
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
if !m.seams.get(ei).copied().unwrap_or(false) && takes {
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
}
}
@@ -1810,8 +1895,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
.find(|(_, tf)| **tf as usize == f)
.and_then(|(t, _)| m.tri(t))
.map(|[a, bb, c]| (a + bb + c) / 3.0);
let takes = bx.takes(&to2(&pts))
&& (bx.crossing
|| m.surface_patch(f).iter().all(|&face| {
m.face_edges(u32::try_from(face).unwrap_or(u32::MAX))
.iter()
.all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p))))
}));
if let Some(point) = point
&& bx.takes(&to2(&pts))
&& takes
{
out.push(Sel::Face { body: b.name.clone(), index: f, point });
}
@@ -1836,8 +1928,17 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
}
let mut grouped = Vec::new();
for item in out {
for piece in grouped_selection(&app.session, &item) {
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
grouped.push(piece);
}
}
}
let out = grouped;
if let Some(mut d) = app.dialog.take() {
d.take_box(out, add);
d.take_box(&app.session, out, add);
app.dialog = Some(d);
return;
}
@@ -1986,7 +2087,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos());
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let targets = cube_targets();
let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
@@ -2303,6 +2404,255 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
fn cylinder_app() -> SolveApp {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.show_origin = false;
app
}
#[test]
fn circular_items_measure_as_whole_rims_and_surfaces() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let edges: Vec<Sel> = m
.edges
.iter()
.enumerate()
.filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6))
.map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) })
.collect();
let result = app.session.measure_items(&edges);
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}");
app.session.selection = edges;
let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
assert_eq!(d.items().len(), 1);
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) };
let wall_pieces = grouped_selection(&app.session, &wall);
let result = app.session.measure_items(&wall_pieces);
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}");
let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
d.pick(&app.session, wall.clone());
assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets");
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
assert_eq!(d.items().len(), 1, "the other half toggles the same wall");
}
#[test]
fn circular_rim_hover_click_and_dialog_pick_include_both_halves() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap();
let rim = m.circular_rim(top);
assert_eq!(rim.len(), 2);
let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum();
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
// Selecting both pieces must not draw each highlight twice.
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
// Ctrl/Shift clicking either half deselects the entire rim.
select(&mut app, Some(item.clone()), true);
assert!(app.session.selection.is_empty());
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
*chain = false;
}
d.pick(&app.session, item.clone());
assert_eq!(d.items().len(), 2);
d.pick(&app.session, item);
assert!(d.items().is_empty());
}
// Screen-space picking works from either side of the rim as the model turns.
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0));
app.cam.target = Vec3::new(0.0, 0.0, 10.0);
for perspective in [false, true] {
app.ui.perspective = perspective;
let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)));
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
let (_, sel) = candidate(&app, &hits).unwrap();
assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}");
assert_eq!(grouped_selection(&app.session, &sel).len(), 2);
}
}
}
#[test]
fn curved_face_hover_selection_and_dialogs_cross_the_seam() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
assert_eq!(faces.len(), 2);
let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count();
for &f in faces {
let index = f as usize;
let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0;
let [a, c, d] = m.tri(t).unwrap();
let point = (a + c + d) / 3.0;
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
assert_eq!(dialog.items().len(), 2);
dialog.pick(&app.session, item.clone());
assert!(dialog.items().is_empty());
dialog.pick(&app.session, item);
assert_eq!(dialog.items().len(), 2);
// A point elsewhere on the opposite half still toggles the whole surface off.
let other = faces.iter().find(|&&x| x != f).unwrap();
select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true);
assert!(app.session.selection.is_empty());
}
// Planar caps and box corners stay separate.
let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap();
assert_eq!(m.surface_patch(cap), vec![cap]);
app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let st = app.session.world_state();
let box_body = st.bodies.last().unwrap();
for i in 0..box_body.body.face_count() {
assert_eq!(box_body.mesh().surface_patch(i), vec![i]);
}
}
#[test]
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
time += 1.0 / 60.0;
let mut events = Vec::new();
if let Some(p) = pointer {
events.push(egui::Event::PointerMoved(p));
}
if wheel != 0.0 {
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, wheel),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
}
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
let list = window.center() + vec2(140.0, 0.0);
let height = app.cam.half_height();
let target = app.cam.target;
frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
frame(&mut app, Some(list), 0.0);
}
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
}
#[test]
fn floating_dialog_blocks_viewport_hover_and_clicks() {
let mut h = crate::scenario::Harness::new();
h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}));
h.call("ui.view", json!({"view": "fit"}));
h.app.ui.pick_bodies = true;
let p = h.app.viewport.rect.unwrap().center();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model");
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable");
h.app.session.selection.clear();
h.call("ui.prefs", json!({"open": "Navigation"}));
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
assert!(window.contains(p), "the dialog must cover the model at the test point");
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights");
assert!(h.app.viewport.mouse.is_none());
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model");
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights");
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(!h.app.session.selection.is_empty());
}
#[test]
fn floating_dialog_blocks_view_cube_highlights_and_clicks() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width": 800, "height": 700}));
let p = crate::scenario::handle_at("cube:top").unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
h.call("ui.prefs", json!({"open": "Navigation"}));
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
assert!(window.contains(p), "the floating dialog must cover the cube target");
let camera = serde_json::to_value(h.app.cam).unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
}
#[test]
fn section_outlines_follow_the_cut() {
let mut s = solvecraft_engine::Session::default();
+95
View File
@@ -31,3 +31,98 @@ fn adhoc() {
panic!("{path}: {e}");
}
}
#[test]
fn angle_dimension_both_interactive_workflows() {
use serde_json::json;
for preselect in [false, true] {
let mut h = Harness::new();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[30,30]]}}},
{"call":"ui.view","params":{"view":"top"}}
]))
.unwrap();
let picks = json!([
{"click":{"sketch":[20,0]}},
{"click":{"sketch":[15,15]},"shift":true}
]);
if preselect {
h.run(&picks).unwrap();
h.run(&json!([{ "key":"D" }])).unwrap();
} else {
h.run(&json!([{ "key":"D" }])).unwrap();
h.run(&picks).unwrap();
}
h.run(&json!([
{"click":{"sketch":[20,8]}},
{"text":"45"}, {"key":"Enter"},
{"expect":{"errors":0,"param":{"d1":std::f64::consts::FRAC_PI_4}}}
]))
.unwrap();
let sk = &h.app.session.model.state().sketches[0].sketch;
assert!(sk.constraints.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::ConstraintKind::Angle { .. })));
}
}
#[test]
fn home_tab_and_file_recent_open_designs() {
use serde_json::json;
let mut h = Harness::new();
let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned();
h.run(&json!([
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}},
{"call":"engine.execute","params":{"command":"file.save_as","params":{"path":path}}},
{"click":{"handle":"tab:home"}},
{"expect":{"home":true,"documents":1}},
{"click":{"handle":"tab:design:0"}},
{"expect":{"home":false,"bodies":1}},
{"click":{"handle":"tab:home"}},
{"click":{"handle":"home:recent:0"}},
{"expect":{"home":false,"bodies":1,"documents":2}},
{"click":{"handle":"appbar:folder"}},
{"widget":"Open Recent"}
]))
.unwrap();
h.run(&json!([
{"widget":path},
{"expect":{"home":false,"bodies":1,"documents":3}}
]))
.unwrap();
}
#[test]
fn sketch_toolbar_imports_use_file_picker() {
use serde_json::json;
let mut h = Harness::new();
let path = h.dir.join("drawing.svg");
std::fs::write(
&path,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="20mm" viewBox="0 0 40 20"><path d="M 0 0 L 40 0 L 40 20 L 0 20 Z"/></svg>"#,
)
.unwrap();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"choose_file":path},
{"start":"sketch.insert_svg"},
{"expect":{"errors":0,"sketch_curves":{"own":4}}}
]))
.unwrap();
}
#[test]
fn narrow_toolbar_reaches_collapsed_commands() {
use serde_json::json;
let mut h = Harness::new();
h.run(&json!([
{"call":"ui.resize","params":{"width":640,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"]}},
{"click":{"handle":"toolbar:overflow"}},
{"widget":"CONSTRUCT"},
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
{"click":{"handle":"toolbar:construct.plane.offset"}},
{"expect":{"dialog":"OffsetPlane"}}
]))
.unwrap();
}
@@ -1,7 +1,4 @@
[
{
"pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails"
},
{
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
},
@@ -87,6 +87,70 @@
}
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "wall",
"expression": "2.4 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "height",
"expression": "30 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "height",
"expression": "25 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "wall",
"expression": "2 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
@@ -1,7 +1,4 @@
[
{
"pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)"
},
{
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
},
@@ -50,6 +50,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "50"
},
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -199,7 +210,7 @@
"max": [
null,
null,
20
30
]
}
}
@@ -231,4 +242,4 @@
{
"shot": "s07_driven"
}
]
]
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -95,6 +95,14 @@
]
}
},
{
"click": {
"sketch": [
20,
-8
]
}
},
{
"text": "40"
},
@@ -109,6 +117,14 @@
]
}
},
{
"click": {
"sketch": [
48,
12
]
}
},
{
"text": "25"
},
@@ -123,6 +139,14 @@
]
}
},
{
"click": {
"sketch": [
30,
20
]
}
},
{
"text": "10"
},
@@ -219,4 +243,4 @@
{
"shot": "s11_done"
}
]
]
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -236,4 +247,4 @@
{
"shot": "s20_section_driven"
}
]
]
@@ -43,6 +43,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -240,4 +251,4 @@
{
"shot": "s26_drive_typed"
}
]
]
@@ -102,7 +102,7 @@
"click": {
"sketch": [
52,
10
-10
]
}
},
@@ -187,7 +187,7 @@
"click": {
"sketch": [
48,
25
15
]
}
},
@@ -239,4 +239,4 @@
{
"shot": "s27_cut"
}
]
]
@@ -1,7 +1,4 @@
[
{
"pending": "every chevron works; the browser has no Joints folder (solvecraft-shell)"
},
{
"note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view."
},
@@ -82,6 +79,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -90,6 +95,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -105,7 +116,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -122,16 +133,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -0,0 +1,103 @@
[
{
"note": "User bug 1/4 (\"big square\"): with the root active, Create Sketch on a moved component's face puts the sketch on the face where it is shown, its projected loop on the face's edges (not at the component's own coordinates)."
},
{
"call": "engine.execute",
"params": {
"command": "component.create",
"params": {
"name": "C"
}
}
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"length": 40,
"width": 30,
"height": 20
}
}
},
{
"call": "engine.execute",
"params": {
"command": "component.activate",
"params": {
"component": "root"
}
}
},
{
"call": "engine.execute",
"params": {
"command": "occurrence.move",
"params": {
"occurrence": "C:1",
"translate": [
100,
0,
0
]
}
}
},
{
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"start": "sketch.create"
},
{
"click": {
"world": [
120,
15,
20
]
}
},
{
"expect": {
"sketching": true
}
},
{
"expect": {
"sketch_curves": {
"linked": 4,
"own": 0
}
}
},
{
"expect": {
"sketch_world": {
"sketch": "Sketch1",
"point": "p1",
"at": [
140,
0,
20
]
}
}
},
{
"shot": "s43_root_sketch_on_moved_face"
},
{
"start": "sketch.finish"
},
{
"expect": {
"errors": 0
}
}
]
@@ -1,7 +1,4 @@
[
{
"pending": "a root-active sketch on a face of a moved component lands in body-local coordinates (solvecraft-dialogs)"
},
{
"note": "A box in a new component, the component moved +100 in x, the root active: a sketch on the box's top face projects the face's edges where the face is (x 100-140), not at the body's own coordinates (x 0-40)."
},
@@ -1,7 +1,7 @@
[
{"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."},
{"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
{"call": "ui.set", "params": {"upAxis": "y"}},
{"call": "ui.prefs", "params": {"up_axis": "y"}},
{"call": "ui.view", "params": {"view": "home"}},
{"move": {"cube": [0, 0, 1]}},
{"expect": {"shown_handles": ["cube_hover:top"]}},
+6 -3
View File
@@ -54,7 +54,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). |
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports features that now fail. |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. |
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
| `undo`, `redo` | — | Undo / redo one change. |
@@ -69,8 +69,11 @@ Errors: an unknown tool or arguments that don't match the tool's schema are JSON
`-32602` errors; an engine failure (bad command parameters, a feature that fails) is a tool
result with `isError: true` and the engine's message, which the model can read and correct.
`batch` rollback undoes as many steps as the batch added to the undo history; batches that
themselves call `edit.undo`, `file.new` or `doc.open` are not rolled back exactly.
`batch` rollback goes back to an engine checkpoint (`edit.checkpoint`) taken before the first
step: the design, the active sketch and the undo history are restored exactly as they were,
however long the history is. Batches that call `file.new` or `doc.open` get the design back,
not its file path. (An older app without checkpoints falls back to undoing as many steps as
the batch added to the undo history.)
## Demo: an enclosure built over MCP
+22
View File
@@ -0,0 +1,22 @@
# WIP branch reconciliation (#6)
The branches predate the dotted command IDs. Their remaining changes were ported through
`crates/engine/src/legacy_ids.rs` and reconciled with the current implementation.
| Branches | Result |
|---|---|
| `solvecraft-sk`, `solvecraft-d2` | Ported `62d22e2` and `e0b3138`: sketches/projections see placed components; cache keys include placements; moved feature dialogs resolve world picks; Joints uses Browser rows. |
| `solvecraft-b4` | Ported `2c18dac`: canvases carry component ownership and named joint origins follow component placements. |
| `solvecraft-asm` | Ported `bc05805`: joint snap feedback, Drive limits, joint menus, and Motion Study editing/playback controls. |
| `solvecraft-shell`, `solvecraft-shell-b` | Browser chevrons, shared occurrence Move triad, toolbar customization, workspace switcher and status labels are already present on main. Their scenarios remain active; the Joints chevron scenario is now active too. |
| `solvecraft-kdev3`, `solvecraft-kdev`, `solvecraft-kdev2` | Previously landed as `67c79ec`; the old refs were already removed. |
| `solvecraft-kdev4` | The earlier robustness, shell, blends, wasm and measurement work is already present. Ported the remaining `9f98449` stacked-prism sewing and repaired curved-boundary face picking. |
| `solvecraft-params(-build)` | Previously landed as `517b7a9`. |
The source WIP refs are retained until this batch merges. Delete them only after the merged
main contains the reconciled changes and passes CI.
Regression coverage includes root sketches on translated and rotated components, subsequent
component moves, canvas save/reopen, joint origins during pending moves, moved feature dialog
refill, Browser chevrons, sample building, and the UI scenarios. `cargo xtask ci` covers native
tests, wasm, layering and asset attribution.
@@ -0,0 +1,24 @@
{
"description": "A loft cut from a pocket floor beside a round hole it never touches (the hole made first). Slab 900 x 480 x 160, a 557.68 x 100 pocket 120 deep, a 35 mm hole 25 deep 66 mm from the pocket, then a loft cut from a rectangle on the pocket floor (0.5 mm inside its walls) down 4 mm to an 80 x 80 square. The loft's top lies in the pocket floor, and the hole's cylinder kept the exact planar boolean from taking it: the cut failed with a kernel panic. Result: the same body as with the hole made after the loft.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "offset": 690, "name": "Outline"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, -480], "p1": [900, 0]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "BowlTop"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.16, -151.16], "p1": [728.84, -51.16]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": -120, "operation": "cut", "name": "BowlPocket"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "TapHole"}},
{"command": "sketch.circle.center", "params": {"center": [795, -101.16], "radius": 17.5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": -25, "operation": "cut", "name": "TapCut"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 730, "name": "TroughInset"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, -150.66], "p1": [728.34, -51.66]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 726, "name": "DrainSeat"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, -141.16], "p1": [490, -61.16]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.loft", "params": {"sections": [{"sketch": "TroughInset"}, {"sketch": "DrainSeat"}], "operation": "cut", "name": "TroughFall"}}
]
}
@@ -0,0 +1,15 @@
{
"description": "A thin 437 mm bar whose profile has a 3 mm concave arc, cut by a long prism whose top is tilted 21 degrees to the bar (an elliptical cut through the arc's cylinder). The check on the result meshed both bodies at a step from the 920 mm prism, which faceted the arc so coarsely that a correct cut failed the membership test. Result: the bar with its end trimmed by the tilted plane, as a split by that plane gives.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
{"command": "sketch.line", "params": {"points": [[-472, 853.91], [-432, 838.47], [-432, 820], [-472, 820]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
]
}
@@ -0,0 +1,15 @@
{
"description": "The oblique cut of oblique_cut_of_cylinder.json with the prism running past the bar's end. Moving a boolean result back after a shifted retry computed a matrix norm whose root refinement never ended: the app hung at full CPU. Result: the same trimmed bar.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
{"command": "sketch.line", "params": {"points": [[-480, 856.9979999999999], [-432, 838.47], [-432, 820], [-480, 820]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
]
}
@@ -0,0 +1,16 @@
{
"description": "A shallow, wide loft cut starting in a body's top face: slab 900 x 480 x 160, loft cut from a 556.68 x 99 rectangle on the top face down 4 mm to an 80 x 80 square (sides about 0.9 degrees from horizontal). Healing an intermediate boolean result panicked outside the boolean's guard and stopped every later attempt. Result: the slab less the loft, 69 003 015.31 mm³.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [900, 480]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 160, "name": "LoftTop"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, 329.34], "p1": [728.34, 428.34]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 156, "name": "LoftBottom"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, 338.84], "p1": [490, 418.84]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.loft", "params": {"sections": [{"sketch": "LoftTop"}, {"sketch": "LoftBottom"}], "operation": "cut", "name": "LoftCut"}}
]
}
+12
View File
@@ -49,3 +49,15 @@ Changes:
direction (long triangles from a trim loop to the grid's far ends cut through cylinders).
- `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are
dropped (their sources are not vendored).
## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath)
truck-geometry uses it for a matrix's operator norm (an intersection curve scales its tolerance
by it whenever it is moved).
Changes:
- `solver.rs`: the Newton refinement of cubic and quartic roots stops after 100 steps. Near a
double root it could cycle forever: moving a body whose edges a boolean made (the retries
move both bodies and the result back) hung the app.
- `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources
are not vendored).
+40
View File
@@ -0,0 +1,40 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "matext4cgmath"
version = "0.1.0"
authors = ["Yoshinori Tanimura <tanimura@ricos.co.jp>"]
description = "Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition."
homepage = "https://github.com/ricosjp/matext4cgmath"
keywords = [
"cgmath",
"graphics",
"linear-algebra",
]
categories = ["graphics"]
license = "Apache-2.0"
repository = "https://github.com/ricosjp/matext4cgmath"
resolver = "2"
autotests = false
autoexamples = false
autobenches = false
[dependencies.cgmath]
version = "0.18.0"
[dependencies.katexit]
version = "0.1.2"
[dependencies.num-complex]
version = "0.4.0"
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more