Compare commits

..
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 c650d1867f Geometry comparison helpers: STL reader, mesh inertia, sampled Hausdorff distance
spatial.rs in solvecraft-cli: read binary or ASCII STL; bounds, volume, centre
of mass and principal second moments of a closed triangle mesh (signed
tetrahedra, Jacobi eigenvalues); sampled symmetric Hausdorff distance between
two meshes using a uniform grid with an expanding-shell search. Unit test on a
cube (moments and a 0.3 mm offset). Not called from CLI commands yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:54:14 +00:00
lillith 8c13914d6c Ignore repeated initial sketch points while drawing 2026-10-12 09:30:18 -04:00
bherbruckandClaude Opus 5.5 f0292b1606 CONTRIBUTING: failing regression tests without a fix are marked #[ignore = "fails until #N is fixed"]
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 13:08:35 +00:00
lillith 732447d2dc Fix dialog header chevrons to collapse and restore forms 2026-10-12 09:08:31 -04:00
lillith db0a2a007a Apply remaining sketch constraints to complete preselection 2026-10-12 08:48:05 -04:00
lillith ed102b5393 fix: reject degenerate boolean intersection polylines 2026-10-12 08:36:40 -04:00
bherbruck 970da30a99 Merge pull request #207 from crono2250/fix/spacemouse-axis-mapping-198
Fix SpaceMouse axis mapping and apply 3DxWare settings
2026-10-12 08:14:51 -04:00
bherbruck 113ded5fab Merge pull request #222 from ich777/lang-german
feat: add German (de) UI localization
2026-10-12 07:51:46 -04:00
bherbruck b4491ac605 Remove premature craft family affiliation claims 2026-10-12 07:14:49 -04:00
ich777 53b092d287 feat: add German (de) UI localization
Adds German (Deutsch) as a third UI language, following the procedure outlined in docs/localization.md
2026-10-12 08:10:14 +02:00
kenos 25ea43e89f fix: apply 3DxWare SpaceMouse axis settings 2026-10-12 11:52:29 +09:00
26 changed files with 1241 additions and 39 deletions

No files matched your search

+3 -4
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application: SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to kernel, and a desktop UI that agents can drive. Projects published by
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`, [storytold](https://github.com/storytold) can serve as references for implementation conventions.
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
## Product name and compatibility ## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
+1 -1
View File
@@ -55,7 +55,7 @@ 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-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-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/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; degenerate intersection-curve rejection; 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) | | 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) |
## Vendored renderer compatibility fix ## Vendored renderer compatibility fix
+3 -4
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application: SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to kernel, and a desktop UI that agents can drive. Projects published by
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`, [storytold](https://github.com/storytold) can serve as references for implementation conventions.
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
## Product name and compatibility ## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
+4
View File
@@ -138,6 +138,10 @@ press keys, then assert on the result.
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed, A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug. put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
(it can then merge right away), and remove that line in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables, **Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so builds several times faster than `--release`. If a test fails only in a plain debug build, say so
Generated
+1
View File
@@ -3297,6 +3297,7 @@ dependencies = [
"embed-resource", "embed-resource",
"hidapi", "hidapi",
"log", "log",
"quick-xml 0.38.4",
"rfd", "rfd",
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
+1 -1
View File
@@ -4,7 +4,7 @@
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family. model through the built-in MCP server.
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif) ![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
+12
View File
@@ -63,10 +63,16 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Dimensioning and error reporting ## Dimensioning and error reporting
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints. - Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements. and navigation controls, including enlarged displays and nested pending placements.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a - Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual useful reproduction context. The draft is editable, with optional capture of the actual
@@ -289,6 +295,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
continuous collision detection; local thin walls and small details remain limitations. continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md). See [contact-set limits and recovery](docs/contact-sets.md).
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Document-opening performance ## Document-opening performance
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and - Cold rebuild profiling separates parsing, geometry, placement, display meshing and
@@ -304,6 +313,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Kernel ## Kernel
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across - Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local support and shared topology, preserving real steps and intentional boundaries. The local
+3
View File
@@ -21,6 +21,9 @@ mod fuzz;
mod oracle; mod oracle;
mod recipe; mod recipe;
mod seams; mod seams;
// Geometry comparison helpers; not called from the CLI commands yet.
#[cfg_attr(not(test), allow(dead_code))]
mod spatial;
use std::process::ExitCode; use std::process::ExitCode;
+333
View File
@@ -0,0 +1,333 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
+1
View File
@@ -21,6 +21,7 @@ rfd = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
quick-xml = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] } winit = { version = "0.30", default-features = false, features = ["x11"] }
+292 -9
View File
@@ -6,11 +6,14 @@ use std::time::{Duration, Instant};
use egui::Context; use egui::Context;
use hidapi::{DeviceInfo, HidApi}; use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solvecraft_engine::render::Camera; use solvecraft_engine::render::Camera;
use solvecraft_ui_egui::SolveApp; use solvecraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150); const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2); const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes { struct Axes {
@@ -18,6 +21,172 @@ struct Axes {
rotation: [i16; 3], rotation: [i16; 3],
} }
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
}
#[derive(Clone, Copy, Debug, Default)]
struct ProfileAxis {
input: Option<usize>,
output: Option<usize>,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ProfilePart {
Input,
Output,
}
fn axis_index(name: &str) -> Option<usize> {
match name.trim() {
"HIDMultiAxis_X" => Some(0),
"HIDMultiAxis_Y" => Some(1),
"HIDMultiAxis_Z" => Some(2),
"HIDMultiAxis_Rx" => Some(3),
"HIDMultiAxis_Ry" => Some(4),
"HIDMultiAxis_Rz" => Some(5),
_ => None,
}
}
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut mapping = DriverMapping::default();
let mut axis: Option<ProfileAxis> = None;
let mut part = None;
let mut field = Vec::new();
let mut found = false;
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(start) => match start.local_name().as_ref() {
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
b"Input" => part = Some(ProfilePart::Input),
b"Output" => part = Some(ProfilePart::Output),
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
_ => {}
},
Event::Text(text) => {
let value = String::from_utf8_lossy(text.as_ref());
match field.as_slice() {
b"ActionID" => {
if let Some(current) = axis.as_mut() {
match part {
Some(ProfilePart::Input) => current.input = axis_index(&value),
Some(ProfilePart::Output) => current.output = axis_index(&value),
None => {}
}
}
}
b"Enabled" => {
if let Some(current) = axis.as_mut() {
current.enabled = value.trim().eq_ignore_ascii_case("true");
}
}
b"Reversed" => {
if part == Some(ProfilePart::Output)
&& let Some(current) = axis.as_mut()
{
current.reversed = value.trim().eq_ignore_ascii_case("true");
}
}
b"Scale" => {
if part == Some(ProfilePart::Output)
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
&& let Some(current) = axis.as_mut()
{
current.scale = scale.clamp(0.0, 10.0);
}
}
b"OverallScale" => {
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
mapping.overall_scale = scale.clamp(0.0, 10.0);
}
}
_ => {}
}
}
Event::End(end) => match end.local_name().as_ref() {
b"Axis" => {
if let Some(current) = axis.take()
&& let (Some(input), Some(output)) = (current.input, current.output)
{
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
found = true;
}
}
b"Input" | b"Output" => part = None,
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
_ => {}
},
Event::Eof => break,
_ => {}
}
buf.clear();
}
found.then_some(mapping)
}
fn load_driver_mapping() -> Option<DriverMapping> {
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
return None;
}
let xml = std::fs::read(&profile).ok()?;
parse_driver_mapping(&xml)
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Reading { struct Reading {
axes: Axes, axes: Axes,
@@ -107,6 +276,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
pub struct SpaceMouse { pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>, shared: Arc<Mutex<Reading>>,
last_frame: Instant, last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
} }
impl SpaceMouse { impl SpaceMouse {
@@ -118,13 +289,21 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) { if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}"); log::warn!("3D mouse: reader could not start: {error}");
} }
Self { shared, last_frame: now } Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
} }
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) { pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now(); let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05); let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now; self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_default();
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open { if !app.preferences.space_mouse_enabled || app.home.open {
return; return;
} }
@@ -141,7 +320,14 @@ impl SpaceMouse {
} }
app.begin_external_navigation(); app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0))); let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some()); navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16)); ctx.request_repaint_after(Duration::from_millis(16));
} }
} }
@@ -153,15 +339,21 @@ fn normalized(value: i16) -> f64 {
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) } if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
} }
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) { fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() { if !(seconds > 0.0) || !seconds.is_finite() {
return; return;
} }
let [x, y, z] = axes.translation.map(normalized); let [x, y, z] = motion.translation;
let [rx, ry, rz] = axes.rotation.map(normalized); let [rx, ry, rz] = motion.rotation;
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height); // Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
cam.zoom_at((-z * 1.5 * seconds).exp(), None); // profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch { if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds); cam.roll_by(rz * 2.0 * seconds);
} }
@@ -171,6 +363,10 @@ fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: boo
mod tests { mod tests {
use super::*; use super::*;
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
}
#[test] #[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() { fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now(); let now = Instant::now();
@@ -192,15 +388,102 @@ mod tests {
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() { fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default(); let mut cam = Camera::default();
let before = cam; let before = cam;
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false); navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target); assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance); assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw); assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch); assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll); assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll); let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true); navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation); assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid()); assert!(cam.is_valid());
} }
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
}
} }
@@ -0,0 +1,60 @@
//! Regressions against the actual patched truck constructor, run by workspace CI.
use truck_meshalgo::prelude::PolylineCurve;
use truck_modeling::{BoundedCurve, ParametricCurve, Plane, Point3, Sphere};
use truck_shapeops::IntersectionCurveWithParameters;
fn planes() -> (Plane, Plane) {
let o = Point3::new(0.0, 0.0, 0.0);
(Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)), Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 0.0, 1.0)))
}
#[test]
fn degenerate_intersection_polylines_return_none_without_panicking() {
let origin = Point3::new(0.0, 0.0, 0.0);
for points in [
vec![],
vec![origin],
vec![origin, origin],
vec![origin; 3],
vec![origin, Point3::new(f64::NAN, 0.0, 0.0)],
vec![origin, Point3::new(f64::INFINITY, 0.0, 0.0)],
] {
let (a, b) = planes();
let result = std::panic::catch_unwind(|| IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(points.clone()), 0.01));
assert!(result.is_ok(), "constructor panicked for {points:?}");
assert!(result.unwrap().is_none(), "collapsed input must not create a curve: {points:?}");
}
}
#[test]
fn closed_intersection_retains_the_closure_and_spherical_supports() {
let a = Sphere::new(Point3::new(0.0, 0.0, 1.0), 2.0_f64.sqrt());
let b = Sphere::new(Point3::new(0.0, 0.0, -1.0), 2.0_f64.sqrt());
let poly = PolylineCurve(vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(-1.0, 0.0, 0.0),
Point3::new(0.0, -1.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
]);
let curve = IntersectionCurveWithParameters::try_new(a, b, poly, 0.01).unwrap();
let (lo, hi) = curve.range_tuple();
assert_eq!(curve.subs(lo), curve.subs(hi));
for i in 0..=40 {
let p = curve.subs(lo + (hi - lo) * i as f64 / 40.0);
assert!(p.z.abs() < 1e-8, "{p:?}");
assert!(((p.x * p.x + p.y * p.y).sqrt() - 1.0).abs() <= 0.01, "{p:?}");
}
}
#[test]
fn open_intersection_keeps_endpoints_on_both_planes() {
let (a, b) = planes();
let start = Point3::new(1.0, 0.0, 0.0);
let end = Point3::new(3.0, 0.0, 0.0);
let curve = IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(vec![start, end]), 0.01).unwrap();
let (lo, hi) = curve.range_tuple();
assert_eq!(curve.subs(lo), start);
assert_eq!(curve.subs(hi), end);
assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0));
}
+2
View File
@@ -30,6 +30,8 @@ mod heal;
mod helix; mod helix;
mod iges_in; mod iges_in;
mod iges_out; mod iges_out;
#[cfg(test)]
mod intersection_curve_tests;
mod lip; mod lip;
mod loopblend; mod loopblend;
mod measure; mod measure;
+11 -1
View File
@@ -1326,7 +1326,17 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
d.collapsed = !d.collapsed; d.collapsed = !d.collapsed;
} }
ui.label(RichText::new(heading.as_str()).strong().size(12.5)); ui.label(RichText::new(heading.as_str()).strong().size(12.5));
crate::frame::right_aligned(ui, total, 8.0, |ui| ui.label(RichText::new("»").color(t.text_dim))); let chevron_width = ui.spacing().interact_size.x;
crate::frame::right_aligned(ui, total, chevron_width, |ui| {
let chevron = if d.collapsed { "«" } else { "»" };
if ui
.add(egui::Button::new(RichText::new(chevron).color(t.text_dim)).frame(false).min_size(vec2(chevron_width, 0.0)))
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
.clicked()
{
d.collapsed = !d.collapsed;
}
});
}); });
if d.collapsed { if d.collapsed {
return; return;
+21
View File
@@ -594,6 +594,27 @@ mod tests {
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2")); assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
} }
#[test]
fn header_chevron_folds_and_restores_appearance_inputs() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
h.frames(3);
let before = h.app.dialog.clone().unwrap();
h.step(&json!({"widget":"»"})).unwrap();
assert!(h.app.dialog.as_ref().unwrap().collapsed);
assert!(h.widget("Appearance name").is_none());
h.step(&json!({"widget":"«"})).unwrap();
let after = h.app.dialog.as_ref().unwrap();
assert!(!after.collapsed);
assert_eq!(format!("{:?}", after.inputs), format!("{:?}", before.inputs));
let (Kind::Appearance(before), Kind::Appearance(after)) = (&before.kind, &after.kind) else { panic!("appearance dialog") };
assert_eq!(before.color, after.color);
assert_eq!(before.opacity, after.opacity);
assert_eq!(before.search, after.search);
}
#[test] #[test]
fn real_frames_and_unchanged_apply_keep_precise_opacity() { fn real_frames_and_unchanged_apply_keep_precise_opacity() {
let mut h = crate::scenario::Harness::new(); let mut h = crate::scenario::Harness::new();
+2 -1
View File
@@ -33,7 +33,8 @@ impl Catalog {
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json")); static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json")); static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE]; static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN];
pub fn is_supported(id: &str) -> bool { pub fn is_supported(id: &str) -> bool {
LOCALES.iter().any(|catalog| catalog.id == id) LOCALES.iter().any(|catalog| catalog.id == id)
-16
View File
@@ -519,22 +519,6 @@ impl SolveApp {
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. })); let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
return self.start(if edges { "solid.fillet" } else { "solid.extrude" }); return self.start(if edges { "solid.fillet" } else { "solid.extrude" });
} }
// Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them.
if id == "sketch.constraint.fix" {
let ents: Vec<String> = self
.session
.selection
.iter()
.filter_map(|s| match s {
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.collect();
if !ents.is_empty() {
let _ = self.run(id, json!({ "entities": ents }));
return;
}
}
// Construction toggle: selected sketch curves switch at once, otherwise pick them. // Construction toggle: selected sketch curves switch at once, otherwise pick them.
// Construction (X): convert the selection, or switch construction mode; no pick tool. // Construction (X): convert the selection, or switch construction mode; no pick tool.
if id == "sketch.construction" { if id == "sketch.construction" {
+296
View File
@@ -0,0 +1,296 @@
{
"browser.body_name": "Körper {number}",
"browser.component_name": "Komponente {number}",
"browser.parameters": "Parameter ({count})",
"browser.precision": "Genauigkeit: {precision}",
"browser.sketch_name": "Skizze {number}",
"browser.units": "Einheiten: {units}",
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.edit_title": "{title} bearbeiten",
"dialog.select": "↖ Auswählen",
"dialog.selected_count": "↖ {count} ausgewählt",
"document.save_changes": "Änderungen in {name} speichern?",
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
"sketch.draw_curves": "{linetype}-Kurven zeichnen",
"sketch.make_selection": "Auswahl als {linetype} markieren",
"ui.1_degree_of_freedom": "1 Freiheitsgrad",
"ui.3d_sketch": "3D-Skizze",
"ui.about": "Über",
"ui.activate": "Aktivieren",
"ui.active_component": "Aktive Komponente",
"ui.agent_cursor": "Agent-Cursor",
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
"ui.all": "Alle",
"ui.angle": "Winkel",
"ui.angle_decimals": "Winkel-Dezimalstellen",
"ui.animate_view_changes": "Ansichtsänderungen animieren",
"ui.animation": "Animation",
"ui.animation_speed": "Animationsgeschwindigkeit",
"ui.apply": "Anwenden",
"ui.arc": "Kreisbogen",
"ui.assemble": "Zusammenbau",
"ui.autosave_every": "Autospeichern alle",
"ui.axes": "Achsen",
"ui.axis": "Achse",
"ui.bodies": "Körper",
"ui.box": "Block",
"ui.break": "Unterbrechen",
"ui.cancel": "Abbrechen",
"ui.canvases": "Kanvas",
"ui.centerline": "Mittellinie",
"ui.chamfer": "Abschrägung",
"ui.change_workspace": "Arbeitsbereich wechseln",
"ui.chord_length": "Sehnenlänge",
"ui.circle": "Kreis",
"ui.circular_pattern": "Radiales Muster",
"ui.clear_recent": "Zuletzt verwendet leeren",
"ui.clear_the_selection": "Auswahl aufheben",
"ui.close": "Schließen",
"ui.coil": "Schraube",
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
"ui.collinear": "Kollinear",
"ui.combine": "Kombinieren",
"ui.coming_later": "bald verfügbar",
"ui.concentric": "Konzentrisch",
"ui.conic_curve": "Kegelschnitt",
"ui.constant": "Konstant",
"ui.constraints_conflict": "Bedingungen konfliktreich",
"ui.construct": "Konstruktion",
"ui.construction": "Konstruktionslinie",
"ui.copy": "Kopieren",
"ui.counterbore": "Tiefenbohrung",
"ui.countersink": "Senkung",
"ui.create": "Erstellen",
"ui.create_sketch": "Skizze erstellen",
"ui.current": "aktuell",
"ui.curvature": "Krümmung",
"ui.cut": "Ausschneiden",
"ui.cylinder": "Zylinder",
"ui.dark": "Dunkel",
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
"ui.degrees_of_freedom": "Freiheitsgrade",
"ui.delete": "Löschen",
"ui.depth": "Tiefe",
"ui.design": "Design",
"ui.diameter": "Durchmesser",
"ui.dimension": "Maß",
"ui.direction": "Richtung",
"ui.distance": "Abstand",
"ui.distance_1": "Abstand 1",
"ui.distance_2": "Abstand 2",
"ui.document_settings": "Dokumenteneinstellungen",
"ui.don_t_save": "Nicht speichern",
"ui.draft": "Abziehen",
"ui.drawing": "Zeichnung",
"ui.drill_point": "Bohrpunkt",
"ui.edit": "Bearbeiten",
"ui.ellipse": "Ellipse",
"ui.end_radius": "Endradius",
"ui.equal": "Gleich",
"ui.expand": "Aufklappen",
"ui.export_ellipsis": "Exportieren...",
"ui.extend": "Verlängern",
"ui.extent_type": "Ausdehnungstyp",
"ui.extrude": "Extrudieren",
"ui.features": "Funktionen",
"ui.file": "Datei",
"ui.fillet": "Vollrundung",
"ui.finish_sketch": "Skizze beenden",
"ui.fix_unfix": "Fixieren/Lösen",
"ui.flat": "Flach",
"ui.flip": "Umkehren",
"ui.front": "Vorne",
"ui.fully_constrained": "Vollständig bestimmt",
"ui.general": "Allgemein",
"ui.generative_design": "Generatives Design",
"ui.graphics": "Grafik",
"ui.heading_assemble": "ZUSAMMENBAU",
"ui.heading_box": "BLOCK",
"ui.heading_chamfer": "ABSCHRÄGUNG",
"ui.heading_circular_pattern": "RADIALES MUSTER",
"ui.heading_coil": "SCHRAUBE",
"ui.heading_combine": "KOMBINIEREN",
"ui.heading_construct": "KONSTRUKTION",
"ui.heading_create": "ERSTELLEN",
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
"ui.heading_cylinder": "ZYLINDER",
"ui.heading_design": "DESIGN",
"ui.heading_draft": "ABZIEHEN",
"ui.heading_edit": "BEARBEITEN",
"ui.heading_extrude": "EXTRUDIEREN",
"ui.heading_fillet": "VOLLRUNDUNG",
"ui.heading_finish_sketch": "SKIZZE BEENDEN",
"ui.heading_front": "VORNE",
"ui.heading_hole": "BOHRUNG",
"ui.heading_home": "START",
"ui.heading_insert": "EINFÜGEN",
"ui.heading_inspect": "PRÜFEN",
"ui.heading_loft": "LOFT",
"ui.heading_measure": "Messen",
"ui.heading_mesh": "MESH",
"ui.heading_mirror": "SPIEGELN",
"ui.heading_modify": "ÄNDERN",
"ui.heading_move": "VERSCHIEBEN",
"ui.heading_offset_plane": "ABSTANDSEBENE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETER",
"ui.heading_pattern_on_path": "PFADMUSTER",
"ui.heading_pipe": "ROHR",
"ui.heading_plane_at_angle": "EBENE IM WINKEL",
"ui.heading_plastic": "PLASTIK",
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
"ui.heading_revolve": "DREHEN",
"ui.heading_right": "RECHTS",
"ui.heading_scale": "SKALIEREN",
"ui.heading_section_analysis": "QUERSCHNITTSANALYSE",
"ui.heading_select": "AUSWÄHLEN",
"ui.heading_sheet_metal": "BLECH",
"ui.heading_shell": "HÜLLE",
"ui.heading_sketch": "SKIZZE",
"ui.heading_sketch_palette": "SKIZZENPALETTE",
"ui.heading_solid": "KÖRPER",
"ui.heading_sphere": "SPHÄRE",
"ui.heading_split_body": "KÖRPER TRENNEN",
"ui.heading_surface": "FLÄCHE",
"ui.heading_sweep": "FÜHREN",
"ui.heading_thread": "GEWINDE",
"ui.heading_top": "OBEN",
"ui.heading_torus": "TORUS",
"ui.help": "Hilfe",
"ui.hide": "Ausblenden",
"ui.hole": "Bohrung",
"ui.hole_tap_type": "Gewindetyp",
"ui.hole_type": "Bohrungstyp",
"ui.home": "Start",
"ui.horizontal_vertical": "Horizontal/Vertikal",
"ui.insert": "Einfügen",
"ui.inspect": "Prüfen",
"ui.intersect": "Schnitt",
"ui.join": "Verbinden",
"ui.joints": "Gelenke",
"ui.keyboard_shortcuts": "Tastenkürzel",
"ui.language": "Sprache",
"ui.length_decimals": "Längen-Dezimalstellen",
"ui.light": "Hell",
"ui.line": "Linie",
"ui.linetype": "Linientyp",
"ui.loft": "Loft",
"ui.look_at": "Ansehen",
"ui.manufacture": "Fertigung",
"ui.measure": "Messen",
"ui.midpoint": "Mittelpunkt",
"ui.mirror": "Spiegeln",
"ui.modify": "Ändern",
"ui.move": "Verschieben",
"ui.multiple": "Mehrfach",
"ui.named_views": "Benannte Ansichten",
"ui.navigation": "Navigation",
"ui.new_body": "Neuer Körper",
"ui.new_design": "Neues Design",
"ui.normal": "Normal",
"ui.object_type": "Objekttyp",
"ui.objects": "Objekte",
"ui.offer_to_stand_up_y_up_files": "Y-hoch-Dateien aufrecht anbieten",
"ui.offset": "Abstand",
"ui.offset_plane": "Abstandsebene",
"ui.one_side": "Einseitig",
"ui.open": "Öffnen",
"ui.open_ellipsis": "Öffnen...",
"ui.open_recent": "Zuletzt verwendet",
"ui.operation": "Operation",
"ui.options": "Optionen",
"ui.orbit_around": "Umkreisen um",
"ui.origin": "Ursprung",
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
"ui.parallel": "Parallel",
"ui.parameters": "Parameter",
"ui.paste": "Einfügen",
"ui.path": "Pfad",
"ui.pattern_on_path": "Pfadmuster",
"ui.perpendicular": "Senkrecht",
"ui.pipe": "Rohr",
"ui.placement": "Platzierung",
"ui.plane": "Ebene",
"ui.plane_at_angle": "Ebene im Winkel",
"ui.point": "Punkt",
"ui.point_under_the_cursor": "Punkt unter dem Cursor",
"ui.points": "Punkte",
"ui.polygon": "Polygon",
"ui.preferences": "Einstellungen",
"ui.profile": "Profil",
"ui.profile_plane": "Profilebene",
"ui.profiles": "Profile",
"ui.project": "Projizieren",
"ui.quit": "Beenden",
"ui.radius": "Radius",
"ui.radius_type": "Radius-Typ",
"ui.rectangle": "Rechteck",
"ui.rectangular_pattern": "Rechteckmuster",
"ui.redo": "Wiederholen",
"ui.render": "Rendern",
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
"ui.restore_defaults": "Standardeinstellungen wiederherstellen",
"ui.reverse_zoom_direction": "Zoomrichtung umkehren",
"ui.revolve": "Drehen",
"ui.right": "Rechts",
"ui.save": "Speichern",
"ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren",
"ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen",
"ui.selection": "Auswahl",
"ui.shell": "Hülle",
"ui.show": "Einblenden",
"ui.show_constraints": "Bedingungen anzeigen",
"ui.show_dimensions": "Maße anzeigen",
"ui.show_points": "Punkte anzeigen",
"ui.show_profile": "Profil anzeigen",
"ui.show_projected_geometries": "Projizierte Geometrien anzeigen",
"ui.simple": "Einfach",
"ui.simulation": "Simulation",
"ui.single": "Einzel",
"ui.sketch": "Skizze",
"ui.sketch_dimension": "Skizzenmaß",
"ui.sketch_grid": "Skizzenraster",
"ui.sketch_scale": "Skizzenskalierung",
"ui.sketches": "Skizzen",
"ui.slice": "Scheibe",
"ui.slot": "Langloch",
"ui.smooth": "Glatt",
"ui.snap": "Einrasten",
"ui.sphere": "Sphäre",
"ui.spline": "Spline",
"ui.split_body": "Körper trennen",
"ui.start": "Start",
"ui.start_radius": "Startradius",
"ui.sweep": "Führen",
"ui.symmetric": "Symmetrisch",
"ui.symmetry": "Symmetrie",
"ui.system": "System",
"ui.tangent": "Tangential",
"ui.taper_angle": "Kegelwinkel",
"ui.tapped": "Gewindebohrung",
"ui.text": "Text",
"ui.theme": "Design",
"ui.thickness": "Dicke",
"ui.thread": "Gewinde",
"ui.tip_angle": "Spitzenwinkel",
"ui.to_object": "Bis Objekt",
"ui.top": "Oben",
"ui.torus": "Torus",
"ui.trim": "Kürzen",
"ui.two_sides": "Beidseitig",
"ui.ui_scale": "UI-Skalierung",
"ui.undo": "Rückgängig",
"ui.units": "Einheiten",
"ui.units_of_new_designs": "Einheiten für neue Designs",
"ui.untitled": "Ohne Titel",
"ui.variable": "Variabel",
"ui.view_centre": "Ansichtszentrum",
"ui.visual_style": "Ansichtsstil",
"ui.workspace": "Arbeitsbereich",
"ui.zoom_to_the_cursor": "Zum Cursor zoomen"
}
@@ -0,0 +1,44 @@
use crate::scenario::Harness;
use serde_json::json;
#[test]
fn repeated_first_point_is_neutral_and_drawing_can_continue() {
for (command, rest) in [
("sketch.rectangle.two_point", vec![[10, 5, 0]]),
("sketch.rectangle.center", vec![[10, 5, 0]]),
("sketch.rectangle.three_point", vec![[10, 0, 0], [10, 5, 0]]),
("sketch.circle.center", vec![[5, 0, 0]]),
("sketch.circle.three_point", vec![[10, 0, 0], [5, 5, 0]]),
("sketch.line", vec![[10, 5, 0]]),
("sketch.polygon.inscribed", vec![[10, 5, 0]]),
("sketch.slot.center_to_center", vec![[10, 0, 0], [10, 3, 0]]),
] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.cam.distance = 50.0;
h.step(&json!({"start":command})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let p = h.call("ui.at", json!({"sketch":[0,0]}))["result"].clone();
h.call("ui.click", json!({"x":p[0],"y":p[1],"double":true}));
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1, "{command}");
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
for point in rest {
h.step(&json!({"move":{"world":point}})).unwrap();
if command == "sketch.slot.center_to_center" {
// The live width editor is placed at the cursor; Enter accepts it.
h.step(&json!({"key":"Enter"})).unwrap();
} else {
h.step(&json!({"click":{"world":point}})).unwrap();
}
}
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
let state = h.app.session.world_state();
assert!(!state.sketches[0].sketch.curves.is_empty(), "{command}: {:?}; {:?}", h.app.tool, h.app.status);
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before, "{command}");
}
}
+29 -1
View File
@@ -12,6 +12,10 @@ use crate::viewport::{Hit, Proj, pick, sketch_point_at};
#[path = "sketch_display_preview_tests.rs"] #[path = "sketch_display_preview_tests.rs"]
mod display_preview_tests; mod display_preview_tests;
#[cfg(test)]
#[path = "sketch_neutral_click_tests.rs"]
mod neutral_click_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
/// Click points; `n` points complete the shape (0 = chain until finished). /// Click points; `n` points complete the shape (0 = chain until finished).
@@ -144,12 +148,18 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
return; return;
} }
match tool.kind { match tool.kind {
Kind::Draw(_) => { Kind::Draw(n) => {
if let Some(mut p) = sketch_point_at(app, proj, pos) { if let Some(mut p) = sketch_point_at(app, proj, pos) {
// Typed values win over the cursor; lines snap to horizontal/vertical. // Typed values win over the cursor; lines snap to horizontal/vertical.
if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) { if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) {
p = (crate::sketch_dims::effective(app, &tool, p.0), None); p = (crate::sketch_dims::effective(app, &tool, p.0), None);
} }
// A repeated initial point supplies no extent. Keep waiting rather than
// attempting a degenerate shape; explicit typed values still validate.
if n != 1 && tool.pts.len() == 1 && tool.pts.first().is_some_and(|first| first.0 == p.0) && !tool.dims.iter().any(|b| b.locked) {
app.tool = Some(tool);
return;
}
app.tool = Some(tool); app.tool = Some(tool);
place(app, p); place(app, p);
return; return;
@@ -326,7 +336,25 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
/// Only consume a complete, unambiguous selection; command validation owns geometry. /// Only consume a complete, unambiguous selection; command validation owns geometry.
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> { pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
use solvecraft_engine::Sel::{SketchCurve, SketchPoint}; use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
let selected_ids = |curves_only: bool, minimum: usize| -> Option<Vec<&str>> {
if !(minimum..=200).contains(&selection.len()) {
return None;
}
let ids: Vec<&str> = selection
.iter()
.map(|s| match s {
SketchCurve { id } => Some(id.as_str()),
SketchPoint { id } if !curves_only => Some(id.as_str()),
_ => None,
})
.collect::<Option<_>>()?;
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
(unique.len() == ids.len()).then_some(ids)
};
match (command, selection) { match (command, selection) {
("sketch.constraint.fix", _) => selected_ids(false, 1).map(|ids| json!({"entities":ids})),
("sketch.constraint.polygon", _) => selected_ids(true, 3).map(|ids| json!({"lines":ids})),
("sketch.constraint.curvature", [SketchCurve { id: a }, SketchCurve { id: b }]) if a != b => Some(json!({"a":a,"b":b})),
("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})), ("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})),
("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})), ("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
("sketch.constraint.coincident", [a, b]) => { ("sketch.constraint.coincident", [a, b]) => {
@@ -146,3 +146,106 @@ fn rejected_preselection_preserves_document_and_error() {
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error)); assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert!(h.app.session.selection.is_empty()); assert!(h.app.session.selection.is_empty());
} }
#[test]
fn curvature_polygon_and_fix_consume_clicked_operands_atomically() {
for command in ["fix", "curvature", "polygon"] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let picks = match command {
"curvature" => {
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
h.app.run("sketch.spline.control_point", json!({"points":["l1.end",[40,12],[50,16],[60,25]]})).unwrap();
vec![[20., 10.], [45., 14.875]]
}
"polygon" => {
h.app.run("sketch.line", json!({"points":[[10,10],[40,10],[40,30],[10,30]],"closed":true,"infer":false})).unwrap();
vec![[25., 10.], [40., 20.], [25., 30.], [10., 20.]]
}
_ => {
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
h.app.run("sketch.point", json!({"point":[20,25],"id":"p"})).unwrap();
vec![[25., 10.], [20., 25.]]
}
};
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.0;
h.app.cam.target = solvecraft_engine::geom::Vec3::new(30.0, 15.0, 0.0);
h.settle();
for (i, p) in picks.iter().enumerate() {
h.step(&json!({"click":{"sketch":p},"shift":i>0})).unwrap();
}
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}: {:?}", h.app.session.selection);
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
assert!(h.app.tool.is_none());
assert!(h.app.session.selection.is_empty());
assert_eq!(h.app.session.undo.len(), undo + 1);
let st = h.app.session.world_state();
let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
assert!(sk.constraints.iter().any(|c| match command {
"fix" => matches!(c.kind, ConstraintKind::Fix { .. }),
"curvature" => matches!(c.kind, ConstraintKind::Smooth { .. }),
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
}));
match command {
"fix" => {
assert!(sk.curve_locked(sk.curve_index("l1").unwrap()));
assert!(sk.point_locked(sk.resolve_point("p").unwrap()));
}
"curvature" => {
let spline = sk.curves.iter().find(|c| c.id != "l1").unwrap();
let pts: Vec<_> = spline.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
let line = sk.polyline(sk.curve_index("l1").unwrap());
let direction = (line[1] - line[0]).normalized().unwrap();
assert!((pts[1] - pts[0]).cross(direction).abs() < 1e-5);
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3);
}
_ => {
let lengths: Vec<_> = ["l1", "l2", "l3", "l4"]
.iter()
.map(|id| {
let pts = sk.polyline(sk.curve_index(id).unwrap());
pts[0].dist(pts[1])
})
.collect();
assert!(lengths[0] > 1.0);
assert!(lengths.iter().all(|l| (l - lengths[0]).abs() < 1e-6));
}
}
assert!(st.sketch(h.app.session.active_sketch.unwrap()).unwrap().report.ok());
let after = h.app.session.doc.to_json();
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), after);
}
}
#[test]
fn remaining_constraints_preserve_mixed_selections_and_rejected_geometry() {
for command in ["fix", "curvature", "polygon"] {
let mut h = lines();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let mixed = vec![Sel::SketchCurve { id: "l1".into() }, Sel::Body { name: "missing".into() }];
h.app.session.selection = mixed.clone();
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
assert!(h.app.tool.is_some());
assert_eq!(h.app.session.selection, mixed);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
let mut h = lines();
let selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "missing".into() }];
h.app.session.selection = selection.clone();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.constraint.curvature"})).unwrap();
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
assert_eq!(h.app.session.selection, selection);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
+1 -1
View File
@@ -54,7 +54,7 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
2. Update `[workspace.package] version` in root `Cargo.toml`. Then run 2. Update `[workspace.package] version` in root `Cargo.toml`. Then run
`cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package `cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package
versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out. versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out.
SolidCraft does not currently implement the siblings' `cargo xtask version set` command. SolidCraft does not currently implement `cargo xtask version set`.
3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`. 3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
Commit the version files through the normal review flow and land them on `main`. Commit the version files through the normal review flow and land them on `main`.
Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it. Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it.
+3
View File
@@ -7,6 +7,9 @@ are marked `SolveCraft:` in the source.
## truck-shapeops 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) ## truck-shapeops 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
Changes: Changes:
- `transversal/intersection_curve/mod.rs`: reject empty/single-point polylines and
collapsed or non-finite segments before endpoint indexing and normalization. A hidden
constructor re-export lets the kernel workspace tests exercise the actual vendored code.
- `transversal/integrate/mod.rs`: mesh both shells with `robust_triangulation` (faces made by - `transversal/integrate/mod.rs`: mesh both shells with `robust_triangulation` (faces made by
earlier booleans have boundaries that lie on their surface only within tolerance). earlier booleans have boundaries that lie on their surface only within tolerance).
- `transversal/divide_face/mod.rs`: when projecting a loop onto a face's surface, fall back to - `transversal/divide_face/mod.rs`: when projecting a loop onto a face's surface, fall back to
+3
View File
@@ -34,4 +34,7 @@ mod healing;
pub use healing::{RobustSplitClosedEdgesAndFaces, SplitClosedEdgesAndFaces}; pub use healing::{RobustSplitClosedEdgesAndFaces, SplitClosedEdgesAndFaces};
mod transversal; mod transversal;
pub use transversal::{and, or, ShapeOpsCurve, ShapeOpsSurface}; pub use transversal::{and, or, ShapeOpsCurve, ShapeOpsSurface};
// SolveCraft: exercised by kernel regressions in the workspace CI.
#[doc(hidden)]
pub use transversal::IntersectionCurveWithParameters;
mod alternative; mod alternative;
@@ -19,12 +19,21 @@ impl<S> From<IntersectionCurveWithParameters<S>> for IntersectionCurve<PolylineC
impl<S> IntersectionCurveWithParameters<S> impl<S> IntersectionCurveWithParameters<S>
where S: ParametricSurface3D + SearchNearestParameter<D2, Point = Point3> where S: ParametricSurface3D + SearchNearestParameter<D2, Point = Point3>
{ {
/// SolveCraft: construct a projected curve, returning `None` for degenerate input.
pub fn try_new( pub fn try_new(
surface0: S, surface0: S,
surface1: S, surface1: S,
poly: PolylineCurve<Point3>, poly: PolylineCurve<Point3>,
tol: f64, tol: f64,
) -> Option<Self> { ) -> Option<Self> {
// SolveCraft: a point contact is not a curve. Reject it before indexing the
// endpoints, and reject collapsed/non-finite segments before normalizing.
if poly.len() < 2 || poly.windows(2).any(|p| {
let length2 = (p[1] - p[0]).magnitude2();
!length2.is_finite() || length2 <= 0.0
}) {
return None;
}
let mut polyline = PolylineCurve(Vec::new()); let mut polyline = PolylineCurve(Vec::new());
let mut params0 = PolylineCurve(Vec::new()); let mut params0 = PolylineCurve(Vec::new());
let mut params1 = PolylineCurve(Vec::new()); let mut params1 = PolylineCurve(Vec::new());
+3
View File
@@ -2,6 +2,9 @@ mod divide_face;
mod faces_classification; mod faces_classification;
mod integrate; mod integrate;
mod intersection_curve; mod intersection_curve;
// SolveCraft: expose the constructor for workspace regression coverage of the vendored fix.
#[doc(hidden)]
pub use intersection_curve::IntersectionCurveWithParameters;
mod loops_store; mod loops_store;
mod polyline_construction; mod polyline_construction;
pub use integrate::{and, or, ShapeOpsCurve, ShapeOpsSurface}; pub use integrate::{and, or, ShapeOpsCurve, ShapeOpsSurface};