diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5697ded6..e0fc901d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml index 5bc2ef96..dfd48416 100644 --- a/.github/workflows/flatpak-pages.yml +++ b/.github/workflows/flatpak-pages.yml @@ -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 }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4880cd91..92321a60 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) | diff --git a/Cargo.lock b/Cargo.lock index 699ac874..3289c02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 693cb1ce..f6190e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/ROADMAP.md b/ROADMAP.md index 862bb90c..3a7621ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 7a51d47f..6669eaf6 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -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, }; diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 4dcdcc4d..a3a70162 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -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(); + })), } } diff --git a/crates/doc/src/canvas.rs b/crates/doc/src/canvas.rs index a9c07f93..2c7cb9cc 100644 --- a/crates/doc/src/canvas.rs +++ b/crates/doc/src/canvas.rs @@ -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, c: &Canvas) -> crate::Result { + 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") { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8b0e80ea..2b879010 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, - /// 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, /// 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 { + 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> { 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 = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect(); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fcf41553..8cd6a94d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -377,6 +377,10 @@ pub struct Model { empty: Arc, /// 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>, /// 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 = 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, f: &Feature, st: &ModelState) - } let mut tools = Vec::new(); for r in ®ions { + 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::>(); let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); @@ -2281,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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 = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect(); + let sketch_m: Vec = 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. diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 9006f74e..84b30e53 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -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, p: Vec3) -> Result { 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). diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index abdf81c9..cf0328ec 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 10856e30..9f920212 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 72c60881..430c81fa 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -34,6 +34,12 @@ impl NameCell { pub fn new(origin: Option>) -> 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>) -> 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). diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index d61a79fd..5378b1b8 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap, 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> { 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 { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index cdbd6a91..6bee00c8 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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, diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a11dd964..5a4b9016 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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" + ); + } +} diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs new file mode 100644 index 00000000..ccbb9a4b --- /dev/null +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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}"); + } +} diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 5bb71272..1740ac04 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -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()); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64bd563e..cdfcd4cc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 { Ok(json!({"redone": label})) } +fn checkpoint(s: &mut Session, _p: &Value) -> Result { + Ok(json!({"checkpoint": s.checkpoint()})) +} + +fn restore_checkpoint(s: &mut Session, p: &Value) -> Result { + 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 { s.model = solvecraft_doc::Model::new(); s.refresh(); @@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { 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 { @@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { 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 { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index dd7890be..60629d66 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { 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"))?; diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 95dc2dc9..9c978dfc 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec { /// 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> { +fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option { let near = |e: &Vec| 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, 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> = 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::>(); let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum(); let normals: Vec = 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 { 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 = list.iter().map(|v| serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::>()?; + let st = s.world_state(); + let mut sels = Vec::new(); + for value in list { + let mut sel = serde_json::from_value::(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)"))?); diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 82b8759d..5437627f 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -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 { 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) } diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 18983408..db8ae158 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index f8964ab6..0a5d4401 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -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}))?; diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index fcc48cf7..4486817a 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -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", )]; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 4e383532..6b34a4fd 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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 { } 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 { }; 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 { 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 { 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 { 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(¶m).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 { +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 diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index 5bb84a89..8c6061f0 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result { 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 { opacity, flip: bool_(p, "flip").unwrap_or(false), visible: true, + component, }); Ok(json!({"canvas": id, "name": name, "pixels": pixels})) } diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index fe07607d..e600fd72 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -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::().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}"); +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 76c710e2..f79c3697 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -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 { } 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 = if use_chain { @@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result { } } } - 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})) } diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index c2778ced..b7ee7586 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -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 { diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index a4b2de07..b089af96 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -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, 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 { 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 { 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 = 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 { 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); diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index df64a15f..751efdf1 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -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 { + 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:?}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ce89495..beb357bb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -170,6 +170,20 @@ pub struct Session { pub analysis: Option, /// 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, + 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, + pub active_sketch: Option, + pub undo: Vec, + pub redo: Vec, } /// 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)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0311b032..2495c83c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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::>()); + 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 = 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::>(), 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(); diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 32c3373a..296f4808 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -82,6 +82,71 @@ impl Mesh { pub fn face_edges(&self, f: u32) -> Vec { 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 { + 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 { + 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 { diff --git a/crates/geom/src/plane.rs b/crates/geom/src/plane.rs index d644e390..ee51a010 100644 --- a/crates/geom/src/plane.rs +++ b/crates/geom/src/plane.rs @@ -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 }; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 5fe4e25b..3e996d14 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -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 { + (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}; diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 955611f6..f6fc3ba6 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -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 { + 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 { 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::>().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, ®ion.outer.ccw())?]; for h in ®ion.holes { diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index fcd17878..b86c5858 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -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]) -> 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]) -> Option { 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]) -> Solid { shells.push(cur); } if !changed { - return solid; + return None; } - Solid::try_new(shells).unwrap_or(solid) + Solid::try_new(shells).ok() } #[cfg(test)] diff --git a/crates/kernel/src/iges_in/tests.rs b/crates/kernel/src/iges_in/tests.rs index cf3e2594..cd9a832e 100644 --- a/crates/kernel/src/iges_in/tests.rs +++ b/crates/kernel/src/iges_in/tests.rs @@ -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); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 44344d4d..f0e8e878 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index ec3dc7a6..718a7f6d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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> { 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> { 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> { 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> { + 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> { 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 Result> { 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>> { + 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 = 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 { + 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::, None::); + 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> { + 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 = 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, pb: Vec, size: f64, op: BoolOp, eps: f64) -> Result> { let polys = csg(pa, pb, op, eps)?; if polys.is_empty() { diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 1bf6c30a..b5a75d95 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { 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 { 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 { } } // 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 Option> { + if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { + return None; + } + let mut ts: Vec = 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> { + 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::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> { + 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)> = 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 { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() }; + let mut pts: Vec = 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 = 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 = Vec::new(); + for sg in &lp.segs { + segs.extend(split_seg(sg, &pts, tol)?); + } + let segs: Vec = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() }; + loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>()); + } + 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>> { 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 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 { diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 6eeab3dd..c3432651 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -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(); diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 28e53b71..6a5946bb 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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}"); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f050cd49..64aa12f8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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 = + (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(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c307b284..938e0de7 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec { "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 { 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) -> 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) -> 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) -> 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) -> Value { } } } + start.finish(b); text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false) } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 2b4cc1d3..3079d6db 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index a7b3fe42..c02ce3da 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -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, /// 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, } -#[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 = points.iter().filter_map(|i| self.point(*i)).collect(); + let normalized = |ps: &[Vec2]| -> Vec { + 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> = 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::>() == 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)?; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 844bc999..2b5a0079 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub offsets: Vec, /// 3D curves (world coordinates) carried by the sketch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wires: Vec, @@ -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())), _ => {} diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs new file mode 100644 index 00000000..b3f06455 --- /dev/null +++ b/crates/sketch/src/offset.rs @@ -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, + 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 { + 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(()) + } +} diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs index f0f378b0..f1c9d439 100644 --- a/crates/sketch/src/text.rs +++ b/crates/sketch/src/text.rs @@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result 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}")))?; diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 62ff4fe7..ab0fb296 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -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)? diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index f073b10a..5fbc0c8f 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -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, + pub(crate) fold: Option, /// Some(visible) for rows with an eye. - eye: Option, - icon: &'a str, - label: &'a str, - selected: bool, - dim: bool, - color: Option, + pub(crate) eye: Option, + pub(crate) icon: &'a str, + pub(crate) label: &'a str, + pub(crate) selected: bool, + pub(crate) dim: bool, + pub(crate) color: Option, /// Component rows: is it the active one? - radio: Option, + pub(crate) radio: Option, /// 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, - clicked: bool, - double: bool, - secondary: Option, - fold: bool, - eye: bool, - radio: bool, - hovered: bool, +pub(crate) struct RowResp { + pub(crate) rect: Option, + pub(crate) clicked: bool, + pub(crate) double: bool, + pub(crate) secondary: Option, + 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, + pub(crate) resp: Option, /// 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 { 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()); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 6381df63..3b9ba9ab 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec { 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; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f56162aa..27912c30 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -480,10 +480,12 @@ impl Dialog { .collect::>() }) .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, add: bool) { + pub fn take_box(&mut self, s: &Session, sels: Vec, 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 = 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>, 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> { + 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 { +pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option { 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 { } /// The body face containing a point (nearest triangle). -pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option { +pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option { 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 { } } } - 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 { @@ -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) -> Option { + let _refill = Refill::start(); let s = &app.session; let f = s.doc.feature(id)?.clone(); let st = s.world_state(); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index ed679646..07d08727 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option { } /// 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 { + 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 { 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::(fold_id)).unwrap_or(true); - let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option| -> 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:`). + 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 = 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)>> = 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 = 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)); + } +} diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 083645fe..ade77ceb 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -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 = 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:?}"); +} diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 02d186f0..5331e429 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -41,6 +41,10 @@ pub enum Mo { tracks: Vec, 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>, @@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { 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)> { // ---- 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 { 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 = 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 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 = 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); diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index 5f1b1eb1..8c211e6a 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I } let state: Option = 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) }; diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 24feb9f9..deab41d2 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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, - /// The session revision when the page opened (a change closes it). - rev: u64, + pub opened_at: std::collections::BTreeMap, thumbs: HashMap>, samples: HashMap, /// 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( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4093555..35a254d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -155,6 +155,7 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + pub show_in_folder: Option>, /// Where the host reads the anti-aliasing preference at start (a small JSON file). pub graphics_path: Option, } @@ -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::(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; diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index b8c2b7e0..3829dd59 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -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); diff --git a/crates/ui-egui/src/ref_images.rs b/crates/ui-egui/src/ref_images.rs index 60cad5ee..770b5d17 100644 --- a/crates/ui-egui/src/ref_images.rs +++ b/crates/ui-egui/src/ref_images.rs @@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec { .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); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 55f53c76..476ae7da 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -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, diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 413f660d..cfbfe9fe 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -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); } } diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 2f87a9fc..39f02e8f 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -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)) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c928c0f3..38abfc1e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 6a89cdea..681f41ba 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -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(); } } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 244af677..0808c9dd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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, 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 = 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 = + 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, 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 { + 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 = 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, 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(); diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 717a9042..0574bb20 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -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#""#, + ) + .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(); +} diff --git a/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json index 0da30c42..1cec549a 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json +++ b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json @@ -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." }, diff --git a/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json index 30f04daa..c5592787 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json +++ b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json @@ -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": { diff --git a/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json index d2d51a42..9c6e6e9b 100644 --- a/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json +++ b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json @@ -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." }, diff --git a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json index a4bb45de..45c796e3 100644 --- a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json +++ b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json @@ -50,6 +50,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "50" }, diff --git a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json index d94e483d..e0347db9 100644 --- a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json +++ b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json @@ -40,6 +40,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json index 51a108d2..ce212e33 100644 --- a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json +++ b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json @@ -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" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json index 2bcb2933..65f227b4 100644 --- a/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json +++ b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json @@ -40,6 +40,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json index 9a72c5f8..1cdd6062 100644 --- a/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json +++ b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json @@ -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" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json b/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json index ef953588..aaefdc34 100644 --- a/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json +++ b/crates/ui-egui/tests/scenarios/s20_section_with_joint_drive.json @@ -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" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s23_configurations.json b/crates/ui-egui/tests/scenarios/s23_configurations.json index c1aa12b4..3d097ae4 100644 --- a/crates/ui-egui/tests/scenarios/s23_configurations.json +++ b/crates/ui-egui/tests/scenarios/s23_configurations.json @@ -43,6 +43,7 @@ ] } }, + {"click": {"sketch": [20, -8]}}, { "text": "40" }, diff --git a/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json index cc01f43d..1785b799 100644 --- a/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json +++ b/crates/ui-egui/tests/scenarios/s26_drive_joint_typed.json @@ -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" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json index 0df6a84d..e4377d17 100644 --- a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json +++ b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json @@ -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" } -] \ No newline at end of file +] diff --git a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json index 83bcc0db..124b89f4 100644 --- a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json +++ b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json @@ -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" diff --git a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json new file mode 100644 index 00000000..5f2b316e --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json @@ -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 + } + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json index 5fe13d6a..8de5af8e 100644 --- a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json +++ b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json @@ -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)." }, diff --git a/crates/ui-egui/tests/scenarios/view_cube_y_up.json b/crates/ui-egui/tests/scenarios/view_cube_y_up.json index aa6d1967..98b19040 100644 --- a/crates/ui-egui/tests/scenarios/view_cube_y_up.json +++ b/crates/ui-egui/tests/scenarios/view_cube_y_up.json @@ -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"]}}, diff --git a/docs/mcp.md b/docs/mcp.md index c6a996c0..63370d71 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/wip-reconciliation.md b/docs/wip-reconciliation.md new file mode 100644 index 00000000..40559e11 --- /dev/null +++ b/docs/wip-reconciliation.md @@ -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. diff --git a/examples/regressions/loft_cut_beside_round_hole.json b/examples/regressions/loft_cut_beside_round_hole.json new file mode 100644 index 00000000..3816cd49 --- /dev/null +++ b/examples/regressions/loft_cut_beside_round_hole.json @@ -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"}} + ] +} diff --git a/examples/regressions/oblique_cut_of_cylinder.json b/examples/regressions/oblique_cut_of_cylinder.json new file mode 100644 index 00000000..3fd277ef --- /dev/null +++ b/examples/regressions/oblique_cut_of_cylinder.json @@ -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}} + ] +} diff --git a/examples/regressions/oblique_cut_of_cylinder_hang.json b/examples/regressions/oblique_cut_of_cylinder_hang.json new file mode 100644 index 00000000..76aea7db --- /dev/null +++ b/examples/regressions/oblique_cut_of_cylinder_hang.json @@ -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}} + ] +} diff --git a/examples/regressions/shallow_loft_cut_from_top_face.json b/examples/regressions/shallow_loft_cut_from_top_face.json new file mode 100644 index 00000000..a898231b --- /dev/null +++ b/examples/regressions/shallow_loft_cut_from_top_face.json @@ -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"}} + ] +} diff --git a/vendor/README.md b/vendor/README.md index 229a7e85..aa10eece 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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). diff --git a/vendor/matext4cgmath/Cargo.toml b/vendor/matext4cgmath/Cargo.toml new file mode 100644 index 00000000..ff2370c9 --- /dev/null +++ b/vendor/matext4cgmath/Cargo.toml @@ -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 "] +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" + diff --git a/vendor/matext4cgmath/LICENSE b/vendor/matext4cgmath/LICENSE new file mode 100644 index 00000000..1ad4a8f7 --- /dev/null +++ b/vendor/matext4cgmath/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Research Institute for Computational Science Co. Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/matext4cgmath/README.md b/vendor/matext4cgmath/README.md new file mode 100644 index 00000000..c2ce89d7 --- /dev/null +++ b/vendor/matext4cgmath/README.md @@ -0,0 +1,5 @@ +# matext4cgmath + +[![Crates.io](https://img.shields.io/crates/v/matext4cgmath.svg)](https://crates.io/crates/matext4cgmath) [![Docs.rs](https://docs.rs/matext4cgmath/badge.svg)](https://docs.rs/matext4cgmath) + +Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition. diff --git a/vendor/matext4cgmath/src/eigens.rs b/vendor/matext4cgmath/src/eigens.rs new file mode 100644 index 00000000..5f7db7e9 --- /dev/null +++ b/vendor/matext4cgmath/src/eigens.rs @@ -0,0 +1,153 @@ +use crate::*; + +impl EigenValues for Matrix2 { + type EigenValues = [Complex; 2]; + fn eigenvalues(self) -> [Complex; 2] { + solver::solve_quadratic(-self.trace(), self.determinant()) + } +} + +impl EigenValues for Matrix3 { + type EigenValues = [Complex; 3]; + fn eigenvalues(self) -> [Complex; 3] { + let poly2 = self[0][0] * self[1][1] + self[0][0] * self[2][2] + self[1][1] * self[2][2] + - self[0][1] * self[1][0] + - self[1][2] * self[2][1] + - self[0][2] * self[2][0]; + solver::solve_cubic(-self.trace(), poly2, -self.determinant()) + } +} + +impl EigenValues for Matrix4 { + type EigenValues = [Complex; 4]; + fn eigenvalues(self) -> [Complex; 4] { + let poly2 = self[0][0] * self[1][1] + + self[0][0] * self[2][2] + + self[0][0] * self[3][3] + + self[1][1] * self[2][2] + + self[1][1] * self[3][3] + + self[2][2] * self[3][3] + - self[0][1] * self[1][0] + - self[0][2] * self[2][0] + - self[0][3] * self[3][0] + - self[1][2] * self[2][1] + - self[1][3] * self[3][1] + - self[2][3] * self[3][2]; + let poly3 = (self[0][0] * self[1][1] + self[0][0] * self[2][2] + self[1][1] * self[2][2] + - self[0][1] * self[1][0] + - self[0][2] * self[2][0] + - self[1][2] * self[2][1]) + * self[3][3] + + (self[2][0] * self[3][2] - self[2][2] * self[3][0] + self[1][0] * self[3][1] + - self[1][1] * self[3][0]) + * self[0][3] + + (self[2][1] * self[3][2] - self[2][2] * self[3][1] + self[3][0] * self[0][1] + - self[3][1] * self[0][0]) + * self[1][3] + + (self[3][1] * self[1][2] - self[3][2] * self[1][1] + self[3][0] * self[0][2] + - self[3][2] * self[0][0]) + * self[2][3] + + Matrix3::from_cols(self[0].truncate(), self[1].truncate(), self[2].truncate()) + .determinant(); + solver::solve_quartic(-self.trace(), poly2, -poly3, self.determinant()) + } +} + +impl OperatorNorm for Matrix2 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::abs(self[0][0]) + F::abs(self[0][1]), + F::abs(self[1][0]) + F::abs(self[1][1]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::abs(self[0][0]) + F::abs(self[1][0]), + F::abs(self[0][1]) + F::abs(self[1][1]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max(eigens[0].re, eigens[1].re)) + } +} + +impl OperatorNorm for Matrix3 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::max( + F::abs(self[0][0]) + F::abs(self[0][1]) + F::abs(self[0][2]), + F::abs(self[1][0]) + F::abs(self[1][1]) + F::abs(self[1][2]), + ), + F::abs(self[2][0]) + F::abs(self[2][1]) + F::abs(self[2][2]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::max( + F::abs(self[0][0]) + F::abs(self[1][0]) + F::abs(self[2][0]), + F::abs(self[0][1]) + F::abs(self[1][1]) + F::abs(self[2][1]), + ), + F::abs(self[0][2]) + F::abs(self[1][2]) + F::abs(self[2][2]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max(F::max(eigens[0].re, eigens[1].re), eigens[2].re)) + } +} + +impl OperatorNorm for Matrix4 { + #[inline] + fn norm_l1(self) -> F { + F::max( + F::max( + F::max( + F::abs(self[0][0]) + + F::abs(self[0][1]) + + F::abs(self[0][2]) + + F::abs(self[0][3]), + F::abs(self[1][0]) + + F::abs(self[1][1]) + + F::abs(self[1][2]) + + F::abs(self[1][3]), + ), + F::abs(self[2][0]) + F::abs(self[2][1]) + F::abs(self[2][2]) + F::abs(self[2][3]), + ), + F::abs(self[3][0]) + F::abs(self[3][1]) + F::abs(self[3][2]) + F::abs(self[3][3]), + ) + } + #[inline] + fn norm_linf(self) -> F { + F::max( + F::max( + F::max( + F::abs(self[0][0]) + + F::abs(self[1][0]) + + F::abs(self[2][0]) + + F::abs(self[3][0]), + F::abs(self[0][1]) + + F::abs(self[1][1]) + + F::abs(self[2][1]) + + F::abs(self[3][1]), + ), + F::abs(self[0][2]) + F::abs(self[1][2]) + F::abs(self[2][2]) + F::abs(self[3][2]), + ), + F::abs(self[0][3]) + F::abs(self[1][3]) + F::abs(self[2][3]) + F::abs(self[3][3]), + ) + } + #[inline] + fn norm_l2(self) -> F { + let eigens = (self.transpose() * self).eigenvalues(); + F::sqrt(F::max( + F::max(F::max(eigens[0].re, eigens[1].re), eigens[2].re), + eigens[3].re, + )) + } +} diff --git a/vendor/matext4cgmath/src/exp_decomp.rs b/vendor/matext4cgmath/src/exp_decomp.rs new file mode 100644 index 00000000..20a90f21 --- /dev/null +++ b/vendor/matext4cgmath/src/exp_decomp.rs @@ -0,0 +1,106 @@ +use crate::*; + +impl Exponential for Matrix2 {} +impl Exponential for Matrix3 {} +impl Exponential for Matrix4 {} + +impl Decomposition for Matrix2 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let (a0, a1) = (F::sqrt(mag0), F::sqrt(mag1)); + Some(( + Matrix2::from_cols(v0 / a0, v1 / a1), + Matrix2::from_diagonal(Vector2::new(a0, a1)), + Matrix2::new(F::one(), F::zero(), n0, F::one()), + )) + } +} + +impl Decomposition for Matrix3 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let n1 = dot(self[2], v0) / mag0; + let n2 = dot(self[2], v1) / mag1; + let v2 = self[2] - v0 * n1 - v1 * n2; + let mag2 = dot(v2, v2); + if mag2 <= F::zero() { + return None; + } + let (a0, a1, a2) = (F::sqrt(mag0), F::sqrt(mag1), F::sqrt(mag2)); + #[rustfmt::skip] + let res = Some(( + Matrix3::from_cols(v0 / a0, v1 / a1, v2 / a2), + Matrix3::from_diagonal(Vector3::new(a0, a1, a2)), + Matrix3::new( + F::one(), F::zero(), F::zero(), + n0, F::one(), F::zero(), + n1, n2, F::one(), + ), + )); + res + } +} + +impl Decomposition for Matrix4 { + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)> { + let v0 = self[0]; + let mag0 = dot(v0, v0); + if mag0 <= F::zero() { + return None; + } + let n0 = dot(self[1], v0) / mag0; + let v1 = self[1] - v0 * n0; + let mag1 = dot(v1, v1); + if mag1 <= F::zero() { + return None; + } + let n1 = dot(self[2], v0) / mag0; + let n2 = dot(self[2], v1) / mag1; + let v2 = self[2] - v0 * n1 - v1 * n2; + let mag2 = dot(v2, v2); + if mag2 <= F::zero() { + return None; + } + let n3 = dot(self[3], v0) / mag0; + let n4 = dot(self[3], v1) / mag1; + let n5 = dot(self[3], v2) / mag2; + let v3 = self[3] - v0 * n3 - v1 * n4 - v2 * n5; + let mag3 = dot(v3, v3); + if mag3 <= F::zero() { + return None; + } + let (a0, a1, a2, a3) = (F::sqrt(mag0), F::sqrt(mag1), F::sqrt(mag2), F::sqrt(mag3)); + #[rustfmt::skip] + let res = Some(( + Matrix4::from_cols(v0 / a0, v1 / a1, v2 / a2, v3 / a3), + Matrix4::from_diagonal(Vector4::new(a0, a1, a2, a3)), + Matrix4::new( + F::one(), F::zero(), F::zero(), F::zero(), + n0, F::one(), F::zero(), F::zero(), + n1, n2, F::one(), F::zero(), + n3, n4, n5, F::one(), + ), + )); + res + } +} diff --git a/vendor/matext4cgmath/src/lib.rs b/vendor/matext4cgmath/src/lib.rs new file mode 100644 index 00000000..08c8d2a2 --- /dev/null +++ b/vendor/matext4cgmath/src/lib.rs @@ -0,0 +1,120 @@ +//! Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition. + +// SolveCraft: compiler warnings no longer fail the build. +#![deny(clippy::all, rust_2018_idioms)] +#![warn( + missing_docs, + missing_debug_implementations, + trivial_casts, + trivial_numeric_casts, + unsafe_code, + unstable_features, + unused_import_braces, + unused_qualifications +)] +#![allow(clippy::many_single_char_names)] + +pub use cgmath; +use cgmath::*; +use num_complex::Complex; + +mod eigens; +mod exp_decomp; +/// solvers for low dimensional algebraic equations. +pub mod solver; + +/// extension for eigen values +pub trait EigenValues: VectorSpace { + /// the type of the array of eigen values + type EigenValues; + /// calculate eigen values. + /// + /// # Examples + /// + /// ``` + /// use cgmath::*; + /// use matext4cgmath::*; + /// use num_complex::Complex; + /// const EPS: f64 = 1.0e-10; + /// + /// let mat = Matrix2::new(4.0, -2.0, 3.0, -1.0); + /// let mut eigens = mat.eigenvalues(); + /// // Even in the case of real solutions, the order in the array is not guaranteed. + /// eigens.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); + /// assert!(Complex::norm(eigens[0] - 1.0) < EPS); + /// assert!(Complex::norm(eigens[1] - 2.0) < EPS); + /// ``` + fn eigenvalues(self) -> Self::EigenValues; +} + +#[cfg_attr(doc, katexit::katexit)] +/// [operator norms](https://en.wikipedia.org/wiki/Matrix_norm): $L^1$, $L^2$, and $L^\infty$. +/// # Examples +/// +/// ``` +/// use cgmath::*; +/// use matext4cgmath::*; +/// +/// let mat = Matrix2::new(1.0, 3.0, -2.0, 4.0); +/// // L^1 operator norm is the maximum column absolute sumation. +/// assert_eq!(mat.norm_l1(), 6.0); +/// // L^2 operator norm is the maximum singular value. +/// let ans_norm_l2 = (5.0 + f64::sqrt(5.0)) / f64::sqrt(2.0); +/// assert!(f64::abs(mat.norm_l2() - ans_norm_l2) < 1.0e-10); +/// // L^∞ operator norm is the maximum row absolute sumation. +/// assert_eq!(mat.norm_linf(), 7.0); +/// ``` +pub trait OperatorNorm: VectorSpace { + /// operator norm for absolute value sumation: $L^1$. + fn norm_l1(self) -> Self::Scalar; + /// operator norm for Euclidean norm: $L^2$. + fn norm_l2(self) -> Self::Scalar; + /// operator norm for absolute value maximum: $L^{\infty}$. + fn norm_linf(self) -> Self::Scalar; +} + +/// calculate exponential value +pub trait Exponential: OperatorNorm + std::ops::AddAssign + One +where + Self::Scalar: BaseFloat, +{ + /// calculate exponential + /// + /// # Examples + /// + /// ``` + /// use cgmath::*; + /// use matext4cgmath::*; + /// + /// let x = Matrix2::new(0.0, 1.0, -1.0, 0.0); + /// let res = x.exp(); + /// let ans = Matrix2::from_angle(Rad(1.0)); + /// assert!((res - ans).norm_l1() < 1.0e-10); + /// ``` + fn exp(self) -> Self { + use num_traits::{Float, NumCast}; + let eps = ::epsilon(); + let mut a = self; + let mut res = Self::one(); + for i in 2_u16..=64 { + res += a; + a = a * self / ::from(i).unwrap(); + if a.norm_linf() < eps * self.norm_linf() { + break; + } + } + res + } +} + +#[cfg_attr(doc, katexit::katexit)] +/// some decompositions of matrix +pub trait Decomposition: VectorSpace { + #[cfg_attr(doc, katexit::katexit)] + /// Returns $(K, A, N)$ of the Iwasawa decomposition: $M = KAN$. + /// + /// - $K$: orthonormal matrix + /// - $A$: diagonal matrix + /// - $N$: upper-half unipotent matrix + fn iwasawa_decomposition(self) -> Option<(Self, Self, Self)>; +} diff --git a/vendor/matext4cgmath/src/solver.rs b/vendor/matext4cgmath/src/solver.rs new file mode 100644 index 00000000..8713ce3d --- /dev/null +++ b/vendor/matext4cgmath/src/solver.rs @@ -0,0 +1,246 @@ +use crate::*; + +#[cfg_attr(doc, katexit::katexit)] +/// Solve equation: $x^2 + ax + b = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let res = solver::solve_quadratic(3.0, -4.0); +/// // Real solutions are always sorted in order of solution size. +/// assert!(Complex::norm(res[0] + 4.0) < EPS); +/// assert!(Complex::norm(res[1] - 1.0) < EPS); +/// ``` +pub fn solve_quadratic(a: F, b: F) -> [Complex; 2] { + let two = F::one() + F::one(); + let four = two + two; + let det = a * a - four * b; + match det >= F::zero() { + true => { + let h = F::sqrt(det); + [ + Complex::new((-a - h) / two, F::zero()), + Complex::new((-a + h) / two, F::zero()), + ] + } + false => { + let h = F::sqrt(-det); + [Complex::new(-a, h) / two, Complex::new(-a, -h) / two] + } + } +} + +#[cfg_attr(doc, katexit::katexit)] +/// Solve equation: $x^3 + px + q = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::pre_solve_cubic(-7.0, -6.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-2.0), Complex::from(-1.0), Complex::from(3.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn pre_solve_cubic(p: F, q: F) -> [Complex; 3] { + let two = F::one() + F::one(); + let three = two + F::one(); + let sqrt3_2 = F::sqrt(three) / two; + let omega = Complex::new(-F::one() / two, sqrt3_2); + let omega2 = Complex::new(-F::one() / two, -sqrt3_2); + let eps_2 = F::sqrt(F::epsilon()); + + let p_3 = p / three; + let q_2 = q / two; + let alpha2 = q_2 * q_2 + p_3 * p_3 * p_3; + let (x, y) = match alpha2 >= F::zero() { + true => { + let alpha = F::sqrt(alpha2); + let tmpx = -q_2 - alpha; + let tmpy = -q_2 + alpha; + ( + Complex::new( + F::signum(tmpx) * F::powf(F::abs(tmpx), F::one() / three), + F::zero(), + ), + Complex::new( + F::signum(tmpy) * F::powf(F::abs(tmpy), F::one() / three), + F::zero(), + ), + ) + } + false => { + let alphai = F::sqrt(-alpha2); + ( + Complex::powf(Complex::new(-q_2, alphai), F::one() / three), + Complex::powf(Complex::new(-q_2, -alphai), F::one() / three), + ) + } + }; + let mut res = [x + y, omega * x + omega2 * y, omega2 * x + omega * y]; + // precision by Newton method + res.iter_mut().for_each(|x| { + let mut f = *x * *x * *x + *x * p + q; + let mut f_prime = *x * *x * three + p; + // SolveCraft: at most 100 steps. Near a double root (the eigenvalues of a + // translation matrix) the iteration can cycle forever without meeting the tolerance. + let mut steps = 0; + while f.norm() > eps_2 * f_prime.norm() && steps < 100 { + steps += 1; + if f_prime.norm() < eps_2 { + return; + } + *x -= f / f_prime; + f = *x * *x * *x + *x * p + q; + f_prime = *x * *x * three + p; + } + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^3 + ax^2 + bx + c = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::solve_cubic(-3.0, 0.0, 4.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-1.0), Complex::from(2.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn solve_cubic(a: F, b: F, c: F) -> [Complex; 3] { + let two = F::one() + F::one(); + let three = two + F::one(); + let twenty_seven = three * three * three; + let p = b - a * a / three; + let q = c - a * b / three + two * a * a * a / twenty_seven; + let mut res = pre_solve_cubic(p, q); + res.iter_mut().for_each(|x| { + *x -= a / three; + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^4 + px^2 + qx + r = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1e-7; +/// +/// let mut res = solver::pre_solve_quartic(-5.0, 0.0, 4.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-2.0), Complex::from(-1.0), Complex::from(1.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x - y) < EPS); +/// }); +/// ``` +pub fn pre_solve_quartic(p: F, q: F, r: F) -> [Complex; 4] { + let one = F::one(); + let two = one + one; + let four = two + two; + let eps_2 = F::sqrt(F::epsilon()); + + let a = two * p; + let b = p * p - four * r; + let c = -q * q; + let f = solve_cubic(a, b, c); + let a = f[0].sqrt() / two; + let b = f[1].sqrt() / two; + let c = f[2].sqrt() / two; + + let mut res = (0..8) + .map(|i| { + let a = a * F::powi(-F::one(), i % 2); + let b = b * F::powi(-F::one(), (i / 2) % 2); + let c = c * F::powi(-F::one(), (i / 4) % 2); + [-a - b - c, -a + b + c, a - b + c, a + b - c] + }) + .map(|x| { + let f = x + .iter() + .map(|t| (t * t * t * t + t * t * p + t * q + r).norm_sqr()) + .max_by(|x, y| x.partial_cmp(y).unwrap()) + .unwrap(); + (x, f) + }) + .min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()) + .unwrap() + .0; + // refinement by Newton method + res.iter_mut().for_each(|x| { + let mut f = *x * *x * *x * *x + *x * *x * p + *x * q + r; + let mut f_prime = *x * *x * *x * four + *x * p * two + q; + // SolveCraft: at most 100 steps. Near a double root (the eigenvalues of a + // translation matrix) the iteration can cycle forever without meeting the tolerance. + let mut steps = 0; + while f.norm() > eps_2 * f_prime.norm() && steps < 100 { + steps += 1; + if f_prime.norm() < eps_2 { + return; + } + *x -= f / f_prime; + f_prime = *x * *x * *x * four + *x * p * two + q; + f = *x * *x * *x * *x + *x * *x * p + *x * q + r; + } + }); + res +} + +#[cfg_attr(doc, katexit::katexit)] +/// solve equation: $x^4 + ax^3 + bx^2 + cx + d = 0$. +/// +/// # Examples +/// +/// ``` +/// use matext4cgmath::solver; +/// use num_complex::Complex; +/// const EPS: f64 = 1.0e-10; +/// +/// let mut res = solver::solve_quartic(1.0, -7.0, -1.0, 6.0); +/// // Even in the case of real solutions, the order in the array is not guaranteed. +/// res.sort_by(|x, y| x.re.partial_cmp(&y.re).unwrap()); +/// let ans = [Complex::from(-3.0), Complex::from(-1.0), Complex::from(1.0), Complex::from(2.0)]; +/// res.iter().zip(ans).for_each(|(x, y)| { +/// assert!(Complex::norm(x * x * x * x + x * x * x - 7.0 * x * x - x + 6.0) < EPS); +/// assert!(Complex::norm(x - y) < EPS, "{x} {y}"); +/// }); +/// ``` +pub fn solve_quartic(a: F, b: F, c: F, d: F) -> [Complex; 4] { + let one = F::one(); + let two = one + one; + let three = one + two; + let four = two + two; + let six = two * three; + let eight = four + four; + + let a_4 = a / four; + let p = b - six * a_4 * a_4; + let q = c - two * b * a_4 + eight * a_4 * a_4 * a_4; + let r = d - c * a_4 + b * a_4 * a_4 - three * a_4 * a_4 * a_4 * a_4; + let mut res = pre_solve_quartic(p, q, r); + res.iter_mut().for_each(|x| *x -= a_4); + res +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 0a65c1e8..29abb446 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -163,11 +163,10 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {