Compare commits

...
Author SHA1 Message Date
bherbruck 683d45c28d Release SolveCraft 0.1.5 2026-10-10 19:34:26 -04:00
bherbruck 13dd4566ce Merge pull request #102 from CypherNaught-0x/fix/chamfer-sloped-rims
Fix chamfers on sloped end faces and enforce rim clearance
2026-10-10 19:28:48 -04:00
CypherNaugh_0x e575bded0e Fix construction-plane references in mirror, section, and sketches (#99)
Accept construction planes by name or feature ID, preserve parametric mirror references, and reject invalid plane inputs. Retain both mirror-plane and primitive-point regression test modules after resolving the conflict with main.

Validation: cargo xtask ci passed, including 282 engine tests, 138 UI tests, 62 UI scenarios, and WebAssembly.
2026-10-10 19:20:51 -04:00
bherbruck 1a1838c114 Merge pull request #106 from N3MI-DG/view_cube_label_size
View cube: larger face labels
2026-10-10 19:15:10 -04:00
N3MI-DGandClaude Opus 5.5 ff1453f868 View cube face labels are larger: one font size for all six, the widest (BOTTOM) spanning 90% of its face, instead of a fixed 9.5 px that left the names small on the cube. Tests: BOTTOM spans 90% of the face seen straight on, and every label stays within its face from any direction
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:05 +11:00
bherbruck e3ee1b359e Fix cubic input-name repair when loading large designs 2026-10-10 19:04:19 -04:00
CypherNaugh_0x 15bb64c2ab Reject chamfer extensions beyond trimmed rim faces 2026-10-11 00:50:20 +02:00
bherbruck feaed2e3f6 Merge pull request #97 from CypherNaught-0x/fix/dimension-measured-after-solve
sketch.dimension reports the measured value after solving
2026-10-10 18:49:25 -04:00
CypherNaugh_0xandClaude Haiku 5.5 f33efa2b6e The timeline scrolls sideways when its features don't fit the window: with more features than the window is wide, the features past the right edge were clipped and could not be reached
Cause: the feature buttons are placed by absolute x coordinates and take no layout space, so nothing in the panel could scroll. The playback controls and the feature strip shared one row with no scroll area.

Fix: the feature strip (after the playback controls) is a horizontal ScrollArea. The content width is allocated from the drawn features and joints so the scroll range matches them. Clicks, drags, the history marker, and context menus work inside the scrolled strip; the playback controls stay fixed.

Tests: cargo xtask ci passes. Checked by hand in the app with a long feature list. Not covered: auto-scroll while dragging a feature or the marker past the visible edge.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-10 18:37:48 -04:00
CypherNaugh_0x c61b77613a solid.cylinder, box, sphere, torus and coil place by expressions: base, corner and center take numbers or length expressions per coordinate ("basin_width/2", "10 mm") and follow parameter changes, renames and component frames; before, a point that was not three plain numbers (or an unreadable axis) was silently replaced by the origin (or Z). The features now store the point as expressions (plain numbers still saved as numbers, so existing designs read and write the same) and evaluate it on every recompute; an unreadable or out-of-range point or axis is refused with an error. Tests: primitive points take expressions, follow parameters, renames and a moved component, and bad points are refused 2026-10-10 18:37:22 -04:00
CypherNaugh_0x 9c6f9410d2 Fix equal-distance chamfers on sloped end faces 2026-10-11 00:30:28 +02:00
bherbruck 31dadcee63 Add nightly prereleases at midnight Eastern, skipping unchanged commits 2026-10-10 18:26:06 -04:00
bherbruck 4ac803ab52 Release SolveCraft 0.1.4 2026-10-10 18:21:13 -04:00
bherbruck 63fd838f55 Fix hover highlight flicker during viewport navigation
Resolve hover before rendering and suppress it throughout navigation gestures and animated views. Closes #92.
2026-10-10 18:14:53 -04:00
CypherNaugh_0x ade30e9c4e Merge origin/main (with #86): sketch.dimension reports the solved value in the dimension's unit; the spout test expects the solved 30 and 60 degrees 2026-10-11 00:13:51 +02:00
CypherNaugh_0x 9c09e82cff sketch.dimension reports measured on the solved sketch: it reported the value the geometry had before the new dimension was solved, so dimensioning a 99.84 mm trough line to 100 answered measured: 99.84 and looked like the dimension had not taken. The command now measures the new dimension again once the sketch is solved (driven dimensions too), through a new solvecraft_sketch::measure_dimension that shares the solver's own measuring. Tests: lengths, a driven distance and a diameter report their solved values 2026-10-11 00:01:19 +02:00
bherbruck 71a4f3061b Fix document and toolbar tab styling 2026-10-10 18:00:37 -04:00
CypherNaugh_0xandClaude Opus 5.5 1227fab860 Extrude's start_offset says which way it goes: along the sketch normal, also with direction: negative (where a positive offset starts on the far side of the sketch from the extrusion)
The params doc listed start_offset? with no description, and an extrude in direction negative with a positive start offset surprised a user. The behaviour is unchanged (an offset start plane, as in Fusion); the params doc of solid.extrude and the Extent field doc say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
CypherNaugh_0xandClaude Opus 5.5 9a2a3cef8b A sweep along a path with sharp corners follows the corners: sweeping a rim profile along three lines (an open rectangle) gave a body of volume -0.0006 mm3 with its centre of mass at x = -3.5e9, and no error
kernel::sweep swept segment by segment: a line translates the profile along it, an arc turns it. At a sharp corner the next line runs within the plane the profile has reached, so the profile slid sideways in its own plane and the walls folded into an inside-out, zero-volume shell. Paths whose direction changes at a joint (more than 1e-3 rad) are now swept as a polyline by sweep_path, which mitres the corners; arcs become points within 0.01 mm. Smooth paths (lines and tangent arcs) are swept as before. Pipe uses the same path.

Tests: sweep_round_a_sharp_corner_is_mitred sweeps a 2 x 2 square along an L of two 10 mm lines and checks the volume, 80 mm3 (fails on main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
CypherNaugh_0xandClaude Opus 5.5 3086c32a9a A round hole elsewhere on a body no longer breaks a loft cut beside it: a loft cut from a pocket floor (556.68 x 99 down 4 mm to 80 x 80), with a 35 mm hole 66 mm away that it never touches, failed with a kernel panic in TroughFall, while the same cut without the hole, or with the hole made afterwards, worked
The loft's top lies in the pocket floor. truck's boolean cannot take coincident faces, and the exact polygon fallback (planar_boolean) only takes bodies whose faces are all planar, so the hole's cylinder ruled out the only path that handles this cut. planar_boolean now first tries a sliced form: when an axis-aligned slab holds the tool and none of the body's curved faces, the body is split on the slab's planes (planes clear of the body's own faces), the planar middle piece takes the exact boolean, and the pieces outside are glued back on along the cut faces (the middle piece is healed first so its edges match theirs). Pieces in several parts are refused rather than glued into one shell. split_by_plane's half-space code is now ops::half_space, which this uses.

Tests: loft_cut_beside_a_round_hole replays examples/regressions/loft_cut_beside_round_hole.json and checks the exact volume (slab - pocket - prismatoid - hole; fails on main). With the hole made after the loft, the body is the same (volume, area, 19 faces, 42 edges).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
CypherNaugh_0xandClaude Opus 5.5 5249ae84ef Moving a body that a boolean made no longer hangs: a boolean between a cylinder and a tilted plane (an elliptical edge) froze the app at 100 % CPU, because its retries move both bodies and the result back
Moving a body transforms its intersection-curve edges, and truck-geometry 0.5.0 scales each curve's tolerance by the operator norm of the matrix. matext4cgmath 0.1.0 computes it from eigenvalues through a quartic whose Newton refinement loops while the residual is above a tolerance and never gives up. For a translation matrix all four eigenvalues are 1, and near that quadruple root the iteration can cycle forever (about one translation in eight of the boolean retries' shifts, by a scan of sizes 0.1-2000 mm). matext4cgmath is vendored with both Newton loops stopped after 100 steps (vendor/README.md, ATTRIBUTION.md).

truck master no longer computes the norm in transform_by, so the vendored copy can go once a truck release after 0.5.0 is adopted.

Tests: moving_a_body_with_an_intersection_curve_edge_ends (kernel) cuts a cylinder with a tilted box and moves the result by one of the shifts that hung (hangs on main). examples/regressions/oblique_cut_of_cylinder_hang.json is the variant of the oblique cut that hung the app; it hangs on main, and since the previous commit it passes without reaching the shifted retries, so it is replayed by the engine test as a scenario only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
CypherNaugh_0xandClaude Opus 5.5 f3a689f284 A thin part trimmed by a long one is no longer refused as an implausible result: a 437 mm bar with a 3 mm concave arc in its profile, cut by a 920 mm prism with a top tilted 21 degrees, failed with "implausible result volume 3870" (it was 3694 before the cut)
The boolean checks its result by meshing both operands and the result, then sampling membership densely where the operands' boxes overlap. The mesh step came from the larger body's size (0.46 mm here), which facets the bar's 3 mm arc so coarsely that two meshes of the same arc disagree over a few percent of the thin overlap box, and a correct cut failed the 0.4 % mismatch limit. The step now also follows the smallest side of the overlap box (2e-3 of it), never finer than 1e-5 of the size, so it only changes where the bodies meet in a region much thinner than they are. truck's first result for the cut is then accepted, and it matches a split of the bar by the tilted plane.

Tests: oblique_cut_through_a_small_arc_matches_a_split replays examples/regressions/oblique_cut_of_cylinder.json and compares with solid.split_body by the same plane (fails on main). The full suite runs no slower.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
CypherNaugh_0xandClaude Opus 5.5 f17ff913d6 A panic while healing a boolean result no longer ends the boolean: a shallow, wide loft cut from a top face (556.68 x 99 down 4 mm to 80 x 80 in a 900 x 480 x 160 slab) failed with "called Option::unwrap() on a None value"
heal_keep evaluated the result's intersection curves outside any guard. truck panics where such a curve no longer evaluates, and the panic escaped boolean_whole, so the retries and fallbacks that would have succeeded never ran. heal_keep now runs under guard and returns the solid unhealed when healing panics (as its doc already promised for failures), and the boolean builds its result body under guard too, so a result that cannot be built counts as a failed attempt.

Tests: shallow_loft_cut_from_the_top_face replays examples/regressions/shallow_loft_cut_from_top_face.json and checks the exact prismatoid volume (fails on main with the panic).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:57:34 -04:00
54 changed files with 2663 additions and 355 deletions

No files matched your search

+39 -1
View File
@@ -3,6 +3,7 @@
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
@@ -15,6 +16,9 @@
name: Release
on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
push:
branches: [release]
workflow_dispatch:
@@ -39,17 +43,30 @@ jobs:
version:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.changed.outputs.build }}
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: changed
name: Skip unchanged nightly builds
env:
GH_TOKEN: ${{ github.token }}
run: bash packaging/nightly.sh
- id: v
if: steps.changed.outputs.build == 'true'
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
. packaging/env.sh
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
# Unique SemVer without committing daily version bumps to main.
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
fi
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1
@@ -65,6 +82,8 @@ jobs:
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -85,6 +104,7 @@ jobs:
linux:
name: Linux ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
@@ -135,6 +155,7 @@ jobs:
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }}
@@ -172,6 +193,7 @@ jobs:
freebsd:
name: FreeBSD x86_64
needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
@@ -212,6 +234,7 @@ jobs:
macos:
name: macOS universal
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -235,6 +258,7 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
if: needs.version.outputs.build == 'true'
runs-on: windows-latest
strategy:
fail-fast: false
@@ -277,6 +301,7 @@ jobs:
web:
name: Web (wasm)
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -302,8 +327,9 @@ jobs:
if-no-files-found: error
release:
name: Draft GitHub Release or dry-run artifacts
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -323,6 +349,18 @@ jobs:
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Publish nightly pre-release
if: github.event_name == 'schedule'
working-directory: artifacts
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolveCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts
+1
View File
@@ -49,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
Generated
+13 -15
View File
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matext4cgmath"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
dependencies = [
"cgmath",
"katexit",
@@ -3059,7 +3057,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"eframe",
"egui",
@@ -3073,7 +3071,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3082,7 +3080,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"log",
"serde",
@@ -3096,7 +3094,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"log",
"serde",
@@ -3112,7 +3110,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"serde",
"serde_json",
@@ -3120,7 +3118,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3132,7 +3130,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"log",
"serde",
@@ -3147,7 +3145,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3155,7 +3153,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"image",
"serde",
@@ -3164,7 +3162,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"serde",
"serde_json",
@@ -3175,7 +3173,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"egui",
"egui-wgpu",
@@ -3189,7 +3187,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"eframe",
"egui",
@@ -4488,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.3"
version = "0.1.5"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -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" }
+2 -1
View File
@@ -66,7 +66,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
+34
View File
@@ -3,6 +3,7 @@
//! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli bench-open <design.solvecraft> [--parse-only] loading timings as JSON lines
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON
@@ -31,6 +32,7 @@ const USAGE: &str = "usage:
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solvecraft-cli bench-open <design.solvecraft> [--parse-only]
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solvecraft-cli exec <command> [json-params]
solvecraft-cli commands
@@ -74,6 +76,7 @@ fn main() -> ExitCode {
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -351,3 +354,34 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
println!("{}", pretty(&json!(out)));
Ok(())
}
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft> [--parse-only]")?;
let t = std::time::Instant::now();
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if len > solvecraft_engine::io::MAX_DESIGN_BYTES as u64 {
return Err(format!("{path}: file too large"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
let doc = solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
if args.iter().any(|a| a == "--parse-only") {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::new(doc);
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
let t = std::time::Instant::now();
let st = s.world_state();
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
Ok(())
}
+37 -4
View File
@@ -164,6 +164,39 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
}
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
return;
}
let old = p.clone();
for (row, out) in p.iter_mut().enumerate() {
let mut constant = m[3][row];
let mut terms: Vec<String> = Vec::new();
for (col, e) in old.iter().enumerate() {
let c = m[col][row];
if c.abs() < 1e-12 {
continue;
}
match num(e) {
Some(v) => constant += c * v,
None if (c - 1.0).abs() < 1e-12 => terms.push(format!("({e})")),
None => terms.push(format!("{c} * ({e})")),
}
}
let mut e = terms.join(" + ");
if terms.is_empty() {
e = format!("{constant} mm");
} else if constant.abs() >= 1e-12 {
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
}
*out = e;
}
}
impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
@@ -207,12 +240,12 @@ impl FeatureKind {
map_plane_ref(m, neutral);
dir(pull);
}
FeatureKind::Box { corner, .. } => pt(corner),
FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
FeatureKind::Cylinder { base, axis, .. } => {
pt(base);
map_point_expr(m, base);
dir(axis);
}
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center),
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center),
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
dir(dir1);
if let Some(d) = dir2 {
@@ -259,7 +292,7 @@ impl FeatureKind {
}
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
FeatureKind::Coil { base, axis, .. } => {
pt(base);
map_point_expr(m, base);
dir(axis);
}
FeatureKind::ReplaceFace { faces, target, .. } => {
+69 -11
View File
@@ -125,7 +125,8 @@ pub struct Extent {
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<String>,
/// Start offset from the sketch plane (expression).
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<String>,
/// Through all bodies in the direction(s) instead of `distance`.
@@ -225,7 +226,8 @@ pub enum FeatureKind {
body: Option<String>,
},
Box {
corner: Vec3,
#[serde(with = "point_expr")]
corner: PointExpr,
length: String,
width: String,
height: String,
@@ -233,7 +235,8 @@ pub enum FeatureKind {
operation: Operation,
},
Cylinder {
base: Vec3,
#[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")]
axis: Vec3,
radius: String,
@@ -242,13 +245,15 @@ pub enum FeatureKind {
operation: Operation,
},
Sphere {
center: Vec3,
#[serde(with = "point_expr")]
center: PointExpr,
radius: String,
#[serde(default)]
operation: Operation,
},
Torus {
center: Vec3,
#[serde(with = "point_expr")]
center: PointExpr,
major: String,
minor: String,
#[serde(default)]
@@ -481,7 +486,8 @@ pub enum FeatureKind {
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
Coil {
base: Vec3,
#[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")]
axis: Vec3,
diameter: String,
@@ -938,6 +944,45 @@ fn z_axis() -> Vec3 {
Vec3::Z
}
/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so
/// it follows parameter changes. Primitives are placed by one.
pub type PointExpr = [String; 3];
/// A point as expressions (plain numbers, mm).
pub fn point_expr(p: Vec3) -> PointExpr {
[format!("{}", p.x), format!("{}", p.y), format!("{}", p.z)]
}
/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took
/// expressions, so such designs read and write the same), anything else as a string.
mod point_expr {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Coord {
Num(f64),
Expr(String),
}
pub fn serialize<S: Serializer>(p: &super::PointExpr, s: S) -> Result<S::Ok, S::Error> {
let c = |e: &String| match e.parse::<f64>() {
Ok(v) if v.is_finite() && format!("{v}") == *e => Coord::Num(v),
_ => Coord::Expr(e.clone()),
};
[c(&p[0]), c(&p[1]), c(&p[2])].serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<super::PointExpr, D::Error> {
let c = |x: Coord| match x {
Coord::Num(v) => format!("{v}"),
Coord::Expr(e) => e,
};
let [x, y, z] = <[Coord; 3]>::deserialize(d)?;
Ok([c(x), c(y), c(z)])
}
}
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.
@@ -1227,10 +1272,22 @@ impl FeatureKind {
v.extend(distance2.iter().map(String::as_str));
v.extend(angle.iter().map(String::as_str));
}
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
FeatureKind::Sphere { radius, .. } => v.push(radius),
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]),
FeatureKind::Box { length, width, height, corner, .. } => {
v.extend([length.as_str(), width, height]);
v.extend(corner.iter().map(String::as_str));
}
FeatureKind::Cylinder { radius, height, base, .. } => {
v.extend([radius.as_str(), height]);
v.extend(base.iter().map(String::as_str));
}
FeatureKind::Sphere { radius, center, .. } => {
v.push(radius);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Torus { major, minor, center, .. } => {
v.extend([major.as_str(), minor]);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Combine { .. } => {}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
@@ -1307,9 +1364,10 @@ impl FeatureKind {
v.extend(gap.iter().map(String::as_str));
}
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
v.extend([diameter.as_str(), pitch, turns, section_size]);
v.extend(start_angle.iter().map(String::as_str));
v.extend(base.iter().map(String::as_str));
}
FeatureKind::Rib { thickness, depth, .. } => {
v.push(thickness);
+25 -9
View File
@@ -684,6 +684,13 @@ pub(crate) const DESIGN_LEN: &str = "\u{1}design_len";
/// An expression's value. A length that comes out unit-less without being a bare number (a
/// unit-less parameter used as a length) is in the design's units.
/// A point given as length expressions.
fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
let [x, y, z] = p;
let v = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?);
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
}
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
@@ -1537,6 +1544,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
}
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
let base = &point(vals, base)?;
let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
let (d, p, n, s) = (
val(vals, diameter, Kind::Length)?,
@@ -1624,14 +1632,15 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
}
FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
Ok(vec![kernel::box_solid(*corner, *corner + s)?])
let corner = point(vals, corner)?;
Ok(vec![kernel::box_solid(corner, corner + s)?])
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
}
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]),
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]),
FeatureKind::Torus { center, major, minor, .. } => {
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
Ok(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
}
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
let (spots, dir) = hole_spots(st, f)?;
@@ -2689,19 +2698,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Box { corner, length, width, height, operation } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let b = kernel::box_solid(*corner, *corner + s)?;
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?;
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Torus { center, major, minor, operation } => {
let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
let b = kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[])
}
FeatureKind::Combine { target, tools, operation, keep_tools } => {
@@ -2970,7 +2980,13 @@ mod chain_tests {
fn tangent_chain_follows_smooth_edges() {
let mut doc = Document::new("T");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody },
FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "40".into(),
width: "30".into(),
height: "20".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
+65 -4
View File
@@ -197,10 +197,7 @@ fn rigid(m: &crate::Mat) -> bool {
/// Feature input names and component occurrences, which format 1 files may predate.
fn normalise(d: &mut Document) -> Result<()> {
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
for id in ids {
d.name_feature_inputs(id);
}
d.name_all_feature_inputs();
let missing: Vec<(u64, u64)> =
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
for (c, p) in missing {
@@ -213,6 +210,70 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
d.set_param("d2", "1", None, None).unwrap();
for i in 0..5 {
d.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
},
Some(&format!("Box{i}")),
)
.unwrap();
}
d.features[0].param_names = vec!["".into(), "custom".into(), "".into(), "d1".into(), "d2".into()];
d.features[1].param_names.clear();
d.features[2].param_names = vec!["d7".into(), "".into(), "d9".into()];
d.features[3].param_names = vec!["d7".into(), "d11".into(), "d12".into(), "d1".into()];
d.features[4].param_names.clear();
let mut expected = d.clone();
for id in expected.features.iter().map(|f| f.id).collect::<Vec<_>>() {
expected.name_feature_inputs(id);
}
d.name_all_feature_inputs();
assert_eq!(d, expected);
assert_eq!(read(&d.to_json()).unwrap(), d);
}
#[test]
fn large_unnamed_timeline_loads_with_unique_inputs() {
let mut d = Document::new("large input repair");
let id = d
.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
let mut f = d.feature(id).unwrap().clone();
f.param_names.clear();
d.features = (1..=5000)
.map(|id| {
let mut f = f.clone();
f.id = id;
f
})
.collect();
d.next_id = 5001;
let back = read(&d.to_json()).unwrap();
let names: Vec<_> = back.features.iter().flat_map(|f| &f.param_names).collect();
assert_eq!(names.len(), 15000);
assert_eq!(names.first().unwrap().as_str(), "d1");
assert_eq!(names.last().unwrap().as_str(), "d15000");
assert_eq!(names.iter().collect::<std::collections::BTreeSet<_>>().len(), names.len());
}
#[test]
fn versions() {
assert_eq!(version_of("solvecraft/1"), Some(1));
+1 -1
View File
@@ -25,7 +25,7 @@ mod project3d;
pub use project3d::iso_grid;
pub mod sheet;
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
+85
View File
@@ -276,6 +276,32 @@ impl FeatureKind {
v
}
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
/// every other position does; renames and users still find them.
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::point_expr_mut`].
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
// Imports have no inputs (and their STEP text is large: don't clone it).
@@ -372,6 +398,55 @@ impl Document {
}
}
/// Repair a loaded timeline in one pass. Reserve even later features' names, just as
/// `name_feature_inputs` does, without restarting a document scan for every `dN` candidate.
pub(crate) fn name_all_feature_inputs(&mut self) {
let mut taken: BTreeMap<String, usize> = BTreeMap::new();
for name in self.params.iter().map(|p| &p.name).chain(self.features.iter().flat_map(|f| &f.param_names)) {
*taken.entry(name.clone()).or_default() += 1;
}
let mut next = 1usize;
for f in &mut self.features {
let n = f.kind.inputs().len();
for k in 0..n {
if f.param_names.get(k).is_some_and(|name| !name.is_empty()) {
continue;
}
let name = loop {
let name = format!("d{next}");
next += 1;
if !taken.contains_key(&name) {
break name;
}
};
if f.param_names.len() <= k {
f.param_names.resize(k + 1, String::new());
}
// Empty slots cannot collide with a generated name.
if let Some(slot) = f.param_names.get_mut(k) {
*slot = name.clone();
}
taken.insert(name, 1);
}
// Match the single-feature repair: surplus names become available only after
// this feature has been repaired. Counts preserve names shared with parameters.
for name in f.param_names.drain(n..) {
if let Some(count) = taken.get_mut(&name) {
*count -= 1;
if *count == 0 {
taken.remove(&name);
if let Some(i) = name.strip_prefix('d').and_then(|s| s.parse::<usize>().ok())
&& name == format!("d{i}")
&& i > 0
{
next = next.min(i);
}
}
}
}
}
}
/// Change a parameter: a stored one, or a feature input by its name.
/// A typed length that is a bare number means the design's units: it gets that unit written
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
@@ -655,6 +730,9 @@ impl Document {
}
}
}
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
for c in &sketch.constraints {
if c.param.as_deref() == Some(name) {
@@ -718,6 +796,13 @@ impl Document {
*x = new.to_string();
}
}
for e in f.kind.point_expr_mut().into_iter().flatten() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for (_, e, _) in f.kind.inputs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
+15 -3
View File
@@ -165,7 +165,13 @@ fn errors_are_reported_not_fatal() {
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "nope".into(),
width: "1".into(),
height: "1".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
@@ -202,13 +208,19 @@ fn params_and_serialization() {
fn primitives_and_combine() {
let mut doc = Document::new("p");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.add_feature(
FeatureKind::Cylinder {
base: Vec3::new(5.0, 5.0, 0.0),
base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
axis: Vec3::Z,
radius: "2".into(),
height: "20".into(),
@@ -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}");
}
}
+77 -37
View File
@@ -1,7 +1,7 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json};
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind};
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
use solvecraft_geom::Vec3;
use super::CommandSpec;
@@ -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")
@@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
@@ -46,7 +46,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (a construction plane keeps the mirror following it)"),
CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
@@ -484,19 +484,59 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
}
}
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
/// the origin when it is not given. Anything else is refused (never read as the origin).
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
let Some(v) = p.get(key) else { return Ok(None) };
let want = || bad(cmd, format!("`{key}` must be a point [x, y, z] of numbers or length expressions"));
let coords: Vec<&Value> = match v {
Value::Array(a) if a.len() == 3 => a.iter().collect(),
Value::Object(o) => ["x", "y", "z"].iter().map(|k| o.get(*k)).collect::<Option<Vec<_>>>().ok_or_else(want)?,
_ => return Err(want()),
};
let mut out = PointExpr::default();
for (slot, (c, axis)) in out.iter_mut().zip(coords.into_iter().zip(["x", "y", "z"])) {
let e = expr(&json!({ "v": c }), "v").ok_or_else(want)?;
let x = s.doc.eval(&e, Kind::Length).map_err(|err| bad(cmd, format!("{key} {axis}: {err}")))?;
if !(x.is_finite() && x.abs() < 1e9) {
return Err(bad(cmd, format!("{key} {axis} is out of range")));
}
*slot = e;
}
Ok(Some(out))
}
/// An axis direction: X|Y|Z or a non-zero `[x, y, z]`; `Z` when it is not given.
pub(super) fn axis_dir_param(p: &Value, cmd: &str) -> Result<Vec3> {
match p.get("axis") {
None => Ok(Vec3::Z),
Some(Value::String(a)) => match a.trim().to_ascii_uppercase().as_str() {
"X" => Ok(Vec3::X),
"Y" => Ok(Vec3::Y),
"Z" => Ok(Vec3::Z),
_ => Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
},
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]")),
}
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
}
let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) {
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c,
(None, Some(c)) => {
let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5
// The corner half a size back from the centre (numbers stay numbers).
(None, Some([cx, cy, cz])) => {
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
_ => format!("({c}) - ({size}) / 2"),
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
}
_ => Vec3::ZERO,
_ => point_expr(Vec3::ZERO),
};
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
}
@@ -507,8 +547,8 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
check_expr(s, &height, Kind::Length, cmd, "height")?;
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
let axis = axis_dir_param(p, cmd)?;
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? })
}
@@ -516,7 +556,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
}
@@ -525,7 +565,7 @@ fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? })
}
@@ -829,35 +869,35 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.mirror";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() },
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
_ => None,
};
solvecraft_doc::PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
};
let plane = plane_param(s, p.get("plane"), cmd)?;
let combine = bool_(p, "combine").unwrap_or(false);
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
}
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
/// feature id; kept by name, so the feature follows the plane when it changes), or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
/// plane reads it here.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use solvecraft_doc::PlaneRef;
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal}";
let construction = |r: &str| match s.doc.find_feature(r) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone() }),
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
};
match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }),
_ => Err(bad(cmd, format!("no plane `{n}`"))),
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
Some(Value::String(n)) => construction(n.trim()),
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
},
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(x) => vec3(x).ok_or_else(|| bad(cmd, "the plane's `origin` must be a point [x, y, z]"))?,
};
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
@@ -865,7 +905,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
};
Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? })
}
_ => Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or {origin, normal}")),
_ => Err(bad(cmd, format!("`plane` must be {WANT}"))),
}
}
+4 -13
View File
@@ -6,7 +6,7 @@ use solvecraft_doc::expr::Kind;
use solvecraft_geom::Vec3;
use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles};
use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
base?: [x,y,z] (numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
),
CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE")
@@ -43,17 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.coil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = match p.get("axis") {
None => Vec3::Z,
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
"Z" => Vec3::Z,
_ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
},
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?,
};
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| solvecraft_doc::point_expr(Vec3::ZERO));
let axis = axis_dir_param(p, cmd)?;
let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
+17 -4
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
"plane: XY|XZ|YZ | construction plane name or id | {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",
)];
@@ -24,18 +24,31 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
}
// A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") {
Some(Value::String(n)) => {
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?;
Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis)
}
// A construction plane (name or id): where it is now.
Some(v @ (Value::String(_) | Value::Number(_))) => {
let r = super::features::plane_param(s, Some(v), cmd)?;
let solvecraft_doc::PlaneRef::Construction { name } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
let st = s.model.state();
let pl = st
.construct
.iter()
.find(|c| &c.name == name)
.and_then(|c| if let solvecraft_doc::construct::ConstructGeom::Plane(pl) = &c.geom { Some(*pl) } else { None })
.ok_or_else(|| bad(cmd, format!("construction plane `{name}` is not built (rolled back, suppressed or failed)")))?;
(pl.origin, pl.normal(), None)
}
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
(origin, n, None)
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or {origin, normal}")),
};
let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) {
+13 -11
View File
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
@@ -348,15 +348,8 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
let base = match p.get("plane") {
None => PlaneRef::Origin { name: "XY".into() },
Some(Value::String(n)) => {
if Plane::named(n).is_some() {
PlaneRef::Origin { name: n.to_ascii_uppercase() }
} else if matches!(s.doc.find_feature(n).map(|f| &f.kind), Some(FeatureKind::ConstructionPlane { .. })) {
PlaneRef::Construction { name: n.clone() }
} else {
return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ or a construction plane)")));
}
}
// An origin plane, or a construction plane by name or id.
Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point, as the active component sees the model
@@ -388,7 +381,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
PlaneRef::Custom { plane: Plane::new(origin, x, y).ok_or_else(|| bad(cmd, "degenerate plane"))? }
}
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or an object")),
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or an object")),
};
Ok(match expr(p, "offset") {
Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d },
@@ -1462,6 +1455,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
place_text(sk, &id, text_at);
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.0, "unit": current.1, "sketch": info}));
}
@@ -1477,6 +1472,13 @@ fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
}
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
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
/// dimension so it follows the geometry.
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
@@ -48,6 +48,26 @@ fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
assert_eq!(g["fully_constrained"], true);
}
/// `measured` is the dimension on the solved sketch: a trough drawn 99.84 long and dimensioned
/// 100 reports 100, not the length it had before solving.
#[test]
fn a_dimension_reports_the_solved_value() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [99.84, 40.2]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
assert!((d["measured"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{d}");
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": "40 mm"}));
assert!((d["measured"].as_f64().unwrap() - 40.0).abs() < 1e-6, "{d}");
// A driven dimension measures the solved geometry too.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["p1", "p3"], "driven": true}));
assert!((d["measured"].as_f64().unwrap() - 100.0_f64.hypot(40.0)).abs() < 1e-6, "{d}");
let c = run(&mut s, "sketch.circle.center", json!({"center": [50, 20], "radius": 7.3}));
let c = ids(&c["curves"])[0].clone();
let d = run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 15}));
assert!((d["measured"].as_f64().unwrap() - 15.0).abs() < 1e-6, "{d}");
}
#[test]
fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
@@ -243,7 +263,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
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.3).abs() < 0.1, "{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.
@@ -253,7 +273,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
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() - 59.74).abs() < 0.01, "{d}");
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"]}));
+20 -2
View File
@@ -10,7 +10,7 @@
//! without moved components).
use serde_json::{Map, Value};
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse};
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse};
use solvecraft_geom::Vec3;
use crate::Session;
@@ -203,11 +203,29 @@ impl Mapper<'_> {
}
fn make(&mut self, v: &Value) -> Option<Value> {
let p = vec3(v)?;
let m = self.f.active;
let Some(p) = vec3(v) else { return self.make_expr(&m, v) };
self.local_point(&m, p)
}
/// A point with expression coordinates (`["w / 2", 0, 0]`): mapped as expressions.
fn make_expr(&mut self, m: &Mat, v: &Value) -> Option<Value> {
let a = v.as_array().filter(|a| a.len() == 3)?;
let c = |i: usize| match a.get(i)? {
Value::Number(n) => n.as_f64().filter(|x| x.is_finite()).map(|x| format!("{x}")),
Value::String(e) => Some(e.clone()),
_ => None,
};
let mut p = [c(0)?, c(1)?, c(2)?];
if is_identity(m) {
return None;
}
let inv = mat_inverse(m)?;
map_point_expr(&inv, &mut p);
self.changed = true;
Some(serde_json::json!(p))
}
fn dir(&mut self, v: &Value) -> Option<Value> {
let d = vec3(v)?;
let m = self.first.unwrap_or(self.f.active);
+2
View File
@@ -503,6 +503,8 @@ impl Session {
}
}
#[cfg(test)]
mod boolean_regression_tests;
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
+31
View File
@@ -36,6 +36,8 @@ fn join_extrude_from_a_chamfered_top_face() {
}
mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness;
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
@@ -1648,6 +1650,35 @@ fn unitless_parameters_used_as_lengths_take_the_design_units() {
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}");
}
/// A chamfer on a tapered pocket survives parameter edits and undo/redo.
#[test]
fn chamfer_sloped_basin_rim_recomputes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30}));
for (name, z, p0, p1) in [("Rim", 30, [20, 4], [80, 50]), ("Floor", 10, [30, 20], [70, 40])] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": z, "name": name}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Rim"}, {"sketch": "Floor"}], "operation": "cut"}));
run(&mut s, "parameters.add", json!({"name": "rim_chamfer", "expression": "1 mm"}));
let base = volume(&mut s);
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
let expected = |size: f64| {
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = size / (1.0 + wall_slope.powi(2)).sqrt();
base - 0.5 * size * drop * (60.0 - drop / 3.0)
};
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
let edited = run(&mut s, "parameters.change", json!({"name": "rim_chamfer", "expression": "2 mm"}));
assert_eq!(edited["errors"].as_array().unwrap().len(), 0, "{edited}");
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
run(&mut s, "edit.undo", json!({}));
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
run(&mut s, "edit.redo", json!({}));
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
}
/// Chamfers with two distances or a distance and an angle.
#[test]
fn unequal_chamfers() {
+65
View File
@@ -0,0 +1,65 @@
//! Commands that take a plane accept construction planes by name or id, like the origin planes
//! (user report: `solid.mirror` refused a construction plane although its docs list plane
//! names).
use serde_json::json;
use super::run;
use crate::*;
fn min_x(s: &Session, body: &str) -> f64 {
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
solvecraft_kernel::measure(&b).unwrap().bbox.min.x
}
#[test]
fn mirror_across_a_construction_plane_follows_it() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
let pl = run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": "w / 2", "name": "Mid"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Ledger"}));
let before = s.model.state().bodies.len();
// By name: the copy lands at x = 90…100.
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "Mid"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let copy = st.bodies.last().map(|b| b.name.clone()).unwrap();
assert!((min_x(&s, &copy) - 90.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// Parametric: moving the plane moves the copy.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "60 mm"}));
assert!((min_x(&s, &copy) - 50.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// By id, and case-insensitively by name.
let id = pl["feature"].as_u64().unwrap();
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": id.to_string()}));
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "mid"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 3);
for b in st.bodies.iter().skip(before) {
assert!((min_x(&s, &b.name) - 50.0).abs() < 1e-6, "{}", b.name);
}
}
#[test]
fn unknown_mirror_planes_are_refused_with_the_choices() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Nope"})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": 5})).unwrap_err().to_string();
assert!(e.contains("construction plane"), "{e}");
// A plane object with an unreadable origin is refused, not put at the world origin.
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": {"origin": ["x", 0, 0], "normal": [1, 0, 0]}})).is_err());
// A feature that is not a plane is no plane.
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Box1"})).is_err());
assert_eq!(s.model.state().bodies.len(), 1);
}
/// Section Analysis cuts along a construction plane too.
#[test]
fn section_by_a_construction_plane() {
let mut s = Session::default();
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 7, "name": "Cut"}));
let r = run(&mut s, "inspect.section", json!({"plane": "Cut"}));
assert_eq!(r["section"]["origin"][2].as_f64(), Some(7.0), "{r}");
assert!(s.execute("inspect.section", &json!({"plane": "Nope"})).is_err());
}
+121
View File
@@ -0,0 +1,121 @@
//! Primitive placement points (`base`, `corner`, `center`) take expressions like their sizes do,
//! follow parameter changes, and are refused when they cannot be read (user report: a cylinder
//! with `base: ["basin_width/2", …]` silently landed at the origin).
use serde_json::{Value, json};
use super::run;
use crate::*;
fn bbox(s: &Session, body: &str) -> ([f64; 3], [f64; 3]) {
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
let m = solvecraft_kernel::measure(&b).unwrap();
([m.bbox.min.x, m.bbox.min.y, m.bbox.min.z], [m.bbox.max.x, m.bbox.max.y, m.bbox.max.z])
}
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3)
}
#[test]
fn cylinder_base_expressions_follow_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "100 mm"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": ["basin_width/2", "10 mm", "-2"], "body_name": "Pin"}));
let (lo, hi) = bbox(&s, "Pin");
assert!(near(lo, [45.0, 5.0, -2.0]) && near(hi, [55.0, 15.0, 8.0]), "{lo:?} {hi:?}");
// A parameter change moves it on recompute.
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "60 mm"}));
let (lo, _) = bbox(&s, "Pin");
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
// And it survives a save and reopen.
let text = s.doc.to_json();
assert!(text.contains("basin_width/2"), "{text}");
let s2 = Session::new(solvecraft_doc::Document::from_json(&text).unwrap());
let (lo, _) = bbox(&s2, "Pin");
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
}
#[test]
fn primitive_points_take_expressions() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": ["w", 0, 0], "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 10, "center": [0, "w", 0], "body_name": "B"}));
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["w * 2", 0, 0], "body_name": "C"}));
run(&mut s, "solid.torus", json!({"major": 5, "minor": 1, "center": [0, 0, "w"], "body_name": "D"}));
run(&mut s, "solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": ["-w", 0, 0], "body_name": "E"}));
assert!(near(bbox(&s, "A").0, [40.0, 0.0, 0.0]));
assert!(near(bbox(&s, "B").0, [-20.0, 35.0, -5.0]), "{:?}", bbox(&s, "B"));
assert!(near(bbox(&s, "C").0, [78.0, -2.0, -2.0]));
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 39.0]));
assert!((bbox(&s, "E").0[0] + 45.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert!(near(bbox(&s, "A").0, [20.0, 0.0, 0.0]));
assert!(near(bbox(&s, "B").0, [-10.0, 15.0, -5.0]), "{:?}", bbox(&s, "B"));
assert!(near(bbox(&s, "C").0, [38.0, -2.0, -2.0]));
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 19.0]));
assert!((bbox(&s, "E").0[0] + 25.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
}
/// Points and axes that cannot be read are refused, never silently put at the origin.
#[test]
fn unreadable_primitive_points_are_refused() {
let mut s = Session::default();
let cases: Vec<(&str, Value)> = vec![
("solid.cylinder", json!({"radius": 5, "height": 10, "base": ["nope/2", 0, 0]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": "XY"})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2, "5 deg"]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": ["x", 0, 1]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": [0, 0, 0]})),
("solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, null, 0]})),
("solid.box", json!({"length": 1, "width": 1, "height": 1, "center": {"x": 1}})),
("solid.sphere", json!({"radius": 1, "center": ["a +", 0, 0]})),
("solid.torus", json!({"major": 5, "minor": 1, "center": [1e300, 0, 0]})),
("solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": [true, 0, 0]})),
];
for (cmd, p) in cases {
let before = s.doc.features.len();
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused");
assert_eq!(s.doc.features.len(), before, "{cmd} {p} added a feature");
}
}
/// Renaming a parameter follows into placement points; deleting it is refused while a point
/// uses it. Plain-number points are stored as numbers, as before.
#[test]
fn point_parameters_rename_and_store() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "off", "expression": "30 mm"}));
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["off", 0, 0], "body_name": "S"}));
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [1.5, -2, 0], "body_name": "B"}));
assert!(s.execute("parameters.delete", &json!({"name": "off"})).is_err());
run(&mut s, "parameters.rename", json!({"name": "off", "new_name": "shift"}));
run(&mut s, "parameters.change", json!({"name": "shift", "expression": "10 mm"}));
assert!(near(bbox(&s, "S").0, [8.0, -2.0, -2.0]), "{:?}", bbox(&s, "S"));
let v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let feats = v["features"].as_array().unwrap();
assert_eq!(feats[0]["center"], json!(["shift", 0.0, 0.0]), "{v}");
assert_eq!(feats[1]["corner"], json!([1.5, -2.0, 0.0]), "{v}");
}
/// A primitive placed by an expression in a moved component lands in that component's frame
/// and keeps following its parameter.
#[test]
fn point_expressions_in_a_moved_component() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "10 mm"}));
let c = run(&mut s, "component.create", json!({"name": "Arm"}));
let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]}));
run(&mut s, "component.activate", json!({"component": cid}));
run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 2, "base": ["x0", 60, 0], "body_name": "Pin"}));
let wlo = |s: &Session| s.world_state().body("Pin").unwrap().mesh().bounds().min;
let lo = wlo(&s);
assert!((lo.x - 9.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "20 mm"}));
let lo = wlo(&s);
assert!((lo.x - 19.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
}
+22 -5
View File
@@ -425,13 +425,19 @@ pub const MAX_STL_TRIANGLES: usize = 20_000_000;
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_doc::{FeatureKind, Model, Operation};
use solvecraft_doc::{FeatureKind, Model, Operation, point_expr};
use solvecraft_geom::Vec3;
fn model() -> (Document, Model) {
let mut doc = Document::new("t");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "20".into(), height: "30".into(), operation: Operation::NewBody },
FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "10".into(),
width: "20".into(),
height: "30".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
@@ -558,7 +564,13 @@ mod tests {
fn threemf_round_trip() {
let mut doc = Document::new("t");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody },
FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "40".into(),
width: "30".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
@@ -569,7 +581,7 @@ mod tests {
.unwrap();
doc.add_feature(
FeatureKind::Cylinder {
base: Vec3::new(80.0, 0.0, 0.0),
base: point_expr(Vec3::new(80.0, 0.0, 0.0)),
axis: Vec3::Z,
radius: "6".into(),
height: "15".into(),
@@ -579,7 +591,12 @@ mod tests {
)
.unwrap();
doc.add_feature(
FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody },
FeatureKind::Torus {
center: point_expr(Vec3::new(0.0, 80.0, 0.0)),
major: "12".into(),
minor: "3".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
+144 -12
View File
@@ -1,8 +1,8 @@
//! Edge fillets and chamfers as a local B-rep operation.
//!
//! Supported: a straight, convex edge between two planar faces whose two end vertices each
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc
//! Supported locally: a straight edge between two planar faces whose two end vertices each
//! touch exactly one more planar face. Fillets require perpendicular end faces; chamfers
//! intersect their offset rails with the end planes, including sloped walls. The end faces get an arc
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
//! The result is exact. Edges meeting at an already blended corner, curved edges and other
//! configurations are reported as not supported yet.
@@ -131,6 +131,60 @@ fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
}
}
/// An extended corner edge must stay in the trimmed side face, not just its infinite
/// supporting plane. Reject crossings and contact with any boundary after the old corner,
/// including holes. Curved boundaries are sampled more finely than the clearance tolerance.
fn chamfer_extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool {
use mt::{BoundedCurve, ParameterDivision1D};
// truck requires a curve sampling tolerance of at least 1e-6. Keep the
// boundary clearance four times larger than the chord approximation error.
let tol = tol.max(4e-6);
let (Some(x), Some(n)) = ((q - p).normalized(), plane_normal(face)) else { return false };
let y = n.cross(x);
let len = p.dist(q);
if !len.is_finite() || len <= tol {
return false;
}
let mut inside = false;
for edge in face.edge_iter() {
let points = if matches!(edge.curve(), mt::Curve::Line(_)) {
vec![edge.front().point(), edge.back().point()]
} else {
let curve = edge.curve();
curve.parameter_division(curve.range_tuple(), tol * 0.25).1
};
if points.len() < 2 || points.len() > 100_000 {
return false;
}
for pair in points.windows(2) {
let [a, b] = pair else { return false };
let a = from_p3(*a) - p;
let b = from_p3(*b) - p;
let (ax, ay, bx, by) = (a.dot(x), a.dot(y), b.dot(x), b.dot(y));
if ![ax, ay, bx, by].iter().all(|v| v.is_finite()) {
return false;
}
// Contact/overlap with the extension, except its original endpoint.
if (ay.abs() <= tol && by.abs() <= tol && ax.max(bx) > tol && ax.min(bx) <= len + tol)
|| [(ax, ay), (bx, by)].iter().any(|(u, v)| *u > tol && *u <= len + tol && v.abs() <= tol)
{
return false;
}
if (ay > 0.0) != (by > 0.0) {
let hit = ax - ay * (bx - ax) / (by - ay);
if hit > tol && hit <= len + tol {
return false;
}
// Even-odd containment of q over all loops (outer boundary and holes).
if hit > len {
inside = !inside;
}
}
}
}
inside
}
#[allow(clippy::too_many_arguments)]
fn blend_geometry(
solid: &Solid,
@@ -153,6 +207,8 @@ fn blend_geometry(
g: usize,
e1: mt::Edge,
e2: mt::Edge,
a: Vec3,
b: Vec3,
}
let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] {
@@ -161,9 +217,19 @@ fn blend_geometry(
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if ng.dot(d).abs() < 1.0 - 1e-9 {
if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
}
if ng.dot(d).abs() < 1e-9 {
return Err(unsupported("an end face parallel to the edge"));
}
// Each rail is parallel to the original edge and set back by s in its side
// face. Slide its endpoint along d until it lies on the actual end plane.
let endpoint = |t: Vec3| {
let q = p + t * s;
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
};
let (a, b) = (endpoint(t1), endpoint(t2));
// Edges at v shared by g with f1 and with f2.
let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
@@ -172,26 +238,46 @@ fn blend_geometry(
.ok_or_else(|| unsupported("unexpected corner topology"))
};
let (e1, e2) = (shared(i1)?, shared(i2)?);
for (e, t) in [(&e1, t1), (&e2, t2)] {
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t);
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
let delta = other - p;
if shape == Shape::Round {
// Fillets retain the square-end check: the original corner edge
// must run in the setback direction and have enough length left.
let along = delta.dot(t);
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
continue;
}
let len = delta.len();
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
let along = (q - p).dot(dir);
// An inner rim extends the neighbouring rim edge away from its other
// vertex. This is valid only within the trimmed side face.
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|| along >= len - tol
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| chamfer_extension_fits(f, p, q, tol)))
{
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
ends.push(End { v: v.clone(), g, e1, e2 });
ends.push(End { v: v.clone(), g, e1, e2, a, b });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g {
return Err(unsupported("both ends touch the same face"));
}
let a0 = builder::vertex(p3(p0 + t1 * s));
let b0 = builder::vertex(p3(p0 + t2 * s));
let a1 = builder::vertex(p3(p1 + t1 * s));
let b1 = builder::vertex(p3(p1 + t2 * s));
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
let a0 = builder::vertex(p3(end0.a));
let b0 = builder::vertex(p3(end0.b));
let a1 = builder::vertex(p3(end1.a));
let b1 = builder::vertex(p3(end1.b));
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
// middle is the point nearest the old edge.
@@ -861,3 +947,49 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
#[test]
fn chamfer_extension_cannot_cross_a_hole_and_reenter_the_face() {
let rectangle = |a, b, c, d| Loop2::polygon(&[Vec2::new(a, b), Vec2::new(c, b), Vec2::new(c, d), Vec2::new(a, d)]);
let region =
Region2 { outer: rectangle(0.0, 0.0, 10.0, 10.0), holes: vec![rectangle(4.0, 4.0, 6.0, 6.0), Loop2::circle(Vec2::new(4.0, 1.5), 0.25)] };
for plane in [Plane::XY, Plane::XZ] {
let face = crate::build::face(&plane, &region).unwrap();
let p = plane.to_world(Vec2::new(4.0, 4.0));
let fits = |y| chamfer_extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6);
assert!(fits(2.0));
assert!(!fits(1.5)); // endpoint in the circular hole
assert!(!fits(0.5)); // endpoint in material, but the extension crossed the hole
assert!(!fits(-1e8)); // an arbitrarily long slide cannot escape the face
}
}
#[test]
fn local_chamfer_intersects_sloped_end_planes() {
for slope in [-0.5, 0.5] {
let region = Region2 {
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(40.0, 0.0), Vec2::new(40.0 - slope * 20.0, 20.0), Vec2::new(slope * 20.0, 20.0)]),
holes: vec![],
};
let body = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let pick = [Vec3::new(20.0, 0.0, 20.0)];
let result = blend(&body, &pick, 2.0, Shape::Flat, "chamfer").unwrap();
let removed = 2.0 * (40.0 - 2.0 * slope * 2.0 / 3.0);
let actual = crate::measure(&body).unwrap().volume - crate::measure(&result).unwrap().volume;
assert!((actual - removed).abs() < 1e-6, "{actual} vs {removed}");
assert!(result.validity().is_empty());
// The new corner vertices lie on both original end planes.
for point in [Vec3::new(slope * 2.0, 2.0, 20.0), Vec3::new(40.0 - slope * 2.0, 2.0, 20.0)] {
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
}
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
// Oblique fillet caps need elliptical trims, which this local path does not build.
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
}
}
}
+56 -1
View File
@@ -795,13 +795,68 @@ pub enum PathSeg {
},
}
impl PathSeg {
/// Unit directions of the path where the segment starts and ends.
fn tangents(&self) -> Option<(Vec3, Vec3)> {
match *self {
PathSeg::Line { a, b } => {
let t = (b - a).normalized()?;
Some((t, t))
}
PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized()?;
let end = rotate_about(a, center, k, angle);
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
}
}
}
/// Points along the segment (an arc within `tol` of its chords), without its start.
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
match *self {
PathSeg::Line { b, .. } => vec![b],
PathSeg::Arc { a, center, axis, angle } => {
let Some(k) = axis.normalized() else { return vec![a] };
let r = (a - center).len();
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
}
}
}
}
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
let v = p - center;
let (s, c) = angle.sin_cos();
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
}
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
/// perpendicular to the path (lines translate it, arcs rotate it).
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners.
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
// Segment by segment the profile only follows a path whose direction runs on across each
// joint: at a corner the next line would slide the profile within its own plane.
let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
if corner {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return crate::sweep_path::sweep_path(plane, region, &pts);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
+8 -2
View File
@@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
/// [`heal`], keeping faces apart along lines they were split along on purpose.
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
// Edges a boolean made are intersection curves, which panic where they no longer
// evaluate; the solid then stays as it is.
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
}
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
shells.push(cur);
}
if !changed {
return solid;
return None;
}
Solid::try_new(shells).unwrap_or(solid)
Solid::try_new(shells).ok()
}
#[cfg(test)]
+31 -10
View File
@@ -189,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
r
}
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
fn meeting_size(a: &Body, b: &Body) -> f64 {
let bbox = |x: &Body| {
let mut bb = solvecraft_geom::Aabb3::EMPTY;
for v in x.solid.vertex_iter() {
bb.add(from_p3(v.point()));
}
bb
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let size = a.size().max(b.size());
let tol_m = size * 5e-4;
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
// there coarsely enough to fail the membership test on a correct result.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
@@ -296,7 +313,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
empty_votes += 1;
last = "empty result".into();
}
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
Ok(body) => {
let (ok, v) = check(&body);
if ok {
@@ -498,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
/// Split a body with a plane: the parts on the positive and the negative side of the plane
/// (only the non-empty ones).
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
let mut out = Vec::new();
for positive in [true, false] {
out.extend(half_space(body, plane, positive)?);
}
Ok(out)
}
/// The part of a body on one side of a plane (`None` when nothing is there).
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
body.require_brep("split")?;
let size = body.size();
let mut bb = solvecraft_geom::Aabb3::EMPTY;
@@ -515,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
]),
holes: vec![],
};
let mut out = Vec::new();
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
out.push(part);
}
}
Ok(out)
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
boolean(body, &half, BoolOp::Intersect)
}
+106
View File
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
}
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
};
let size = a.size().max(b.size());
polys_boolean(pa, pb, size, op, size * 1e-9)
}
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b)?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
let mesh = a.tessellate(tol).ok()?;
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if infos.get(*fi as usize)?.plane_normal.is_some() {
continue;
}
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
for p in mesh.tri(t)? {
bx.add(p);
}
}
let mut tool = solvecraft_geom::Aabb3::EMPTY;
for p in pb.iter().flat_map(|p| &p.v) {
tool.add(*p);
}
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
let gap = size * 1e-4;
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
let axis = *axes.get(k)?;
[0.5, 0.37, 0.63, 0.25, 0.75]
.iter()
.map(|t| near + (far - near) * t)
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
};
// An axis along which every curved face lies clear of the tool: the cuts either side.
let (k, lo, hi) = (0..3).find_map(|k| {
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
let (mut below, mut above) = (None::<f64>, None::<f64>);
for bx in curved.values() {
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
if c1 < t0 - gap {
below = Some(below.map_or(c1, |x| x.max(c1)));
} else if c0 > t1 + gap {
above = Some(above.map_or(c0, |x| x.min(c0)));
} else {
return None;
}
}
let lo = match below {
Some(c) => Some(place(k, t0, c)?),
None => None,
};
let hi = match above {
Some(c) => Some(place(k, t1, c)?),
None => None,
};
Some((k, lo, hi))
})?;
let (u, w) = match k {
0 => (Vec3::Y, Vec3::Z),
1 => (Vec3::Z, Vec3::X),
_ => (Vec3::X, Vec3::Y),
};
let axis = *axes.get(k)?;
let run = || -> Result<Option<Body>> {
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
let mut middle = a.clone();
let mut outside = Vec::new();
for (cut, keep_positive) in [(lo, true), (hi, false)] {
let Some(x) = cut else { continue };
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
}
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
if op == BoolOp::Intersect {
return Ok(r);
}
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
// they match the pieces outside edge for edge.
let mut pieces: Vec<Body> = match r {
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
None => Vec::new(),
};
// Each piece outside meets the middle along its cut face (glued one shell to another).
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
return Err(fail("a piece in several parts"));
}
for o in outside {
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
if let Some(slot) = pieces.get_mut(i) {
*slot = j;
}
}
crate::ops::join_pieces(pieces)
};
Some(run())
}
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
let polys = csg(pa, pb, op, eps)?;
if polys.is_empty() {
+76
View File
@@ -258,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.
@@ -403,6 +435,50 @@ fn chamfer_top_loop_mitres() {
assert_eq!(m.merged.faces, 10);
}
#[test]
fn chamfer_sloped_basin_rim() {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let result = chamfer(&basin, &[Vec3::new(50.0, 4.0, 30.0)], 2.0).unwrap();
// The removed triangular section has area s² sin(phi)/2. Its length varies
// linearly between the sloped end walls, so its centroid gives the mean length.
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = 2.0 / (1.0 + wall_slope.powi(2)).sqrt();
let removed = drop * (60.0 - drop / 3.0);
let before = measure(&basin).unwrap();
let after = measure(&result).unwrap();
assert!(rel(after.volume, before.volume - removed) < 1e-8, "{} vs {}", after.volume, before.volume - removed);
assert_eq!(after.merged.faces, before.merged.faces + 1);
assert_eq!(result.lumps().unwrap().len(), 1);
assert!(result.validity().is_empty());
let mesh = result.tessellate(0.01).unwrap();
assert!(!mesh.contains(Vec3::new(50.0, 3.5, 29.9)));
assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0)));
}
#[test]
fn chamfer_wider_than_sloped_basin_rim_is_refused() {
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
.unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let before = measure(&basin).unwrap();
let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err();
assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}");
assert_eq!(measure(&basin).unwrap().volume, before.volume);
// A smaller chamfer on the same thin rim is still supported.
let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap();
assert!(smaller.validity().is_empty());
assert!(measure(&smaller).unwrap().volume < before.volume);
}
}
#[test]
#[ignore]
fn debug_chamfer_cut() {
+1 -1
View File
@@ -29,7 +29,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk
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, solve, solve_holding};
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
+7
View File
@@ -456,6 +456,13 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
})
}
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
/// constraint that is not a dimension.
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 };
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
}
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
/// touch point (better conditioned than the distance form when an end point is on the curve).
enum Block<'a> {
+9 -4
View File
@@ -2801,6 +2801,11 @@ fn pt3(v: Vec3) -> Value {
json!([v.x, v.y, v.z])
}
/// A point of expressions: numbers as numbers, expressions as strings.
fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value {
Value::Array(p.iter().map(|e| e.parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect())
}
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
/// before the feature, so its references show on the geometry they refer to.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
@@ -3008,7 +3013,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
operation: op_index(operation),
};
d.extra.insert("corner".into(), pt3(*corner));
d.extra.insert("corner".into(), pt_expr(corner));
d
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
@@ -3018,14 +3023,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("radius", radius.clone()), ("height", height.clone())],
operation: op_index(operation),
};
d.extra.insert("base".into(), pt3(*base));
d.extra.insert("base".into(), pt_expr(base));
d.extra.insert("axis".into(), pt3(*axis));
d
}
FeatureKind::Sphere { center, radius, operation } => {
let mut d = start("solid.sphere")?;
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
d.extra.insert("center".into(), pt3(*center));
d.extra.insert("center".into(), pt_expr(center));
d
}
FeatureKind::Torus { center, major, minor, operation } => {
@@ -3035,7 +3040,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("major", major.clone()), ("minor", minor.clone())],
operation: op_index(operation),
};
d.extra.insert("center".into(), pt3(*center));
d.extra.insert("center".into(), pt_expr(center));
d
}
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
+3 -3
View File
@@ -298,13 +298,13 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
ui.painter().rect_filled(
home,
5.0,
egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 },
if app.home.open {
t.toolbar
} else if response.hovered() {
t.hover
} else {
Color32::from_white_alpha(10)
t.app_bar
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
@@ -338,7 +338,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
} else if resp.hovered() {
Color32::from_white_alpha(28)
} else {
Color32::from_white_alpha(10)
t.app_bar
};
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
let ink = if active { t.text } else { t.app_bar_text };
-3
View File
@@ -16,7 +16,6 @@ pub fn is_dark() -> bool {
pub struct Tokens {
pub app_bar: Color32,
pub toolbar: Color32,
pub tab_active: Color32,
pub panel: Color32,
pub panel_header: Color32,
pub border: Color32,
@@ -132,7 +131,6 @@ impl Tokens {
Tokens {
app_bar: Color32::from_rgb(52, 56, 64),
toolbar: Color32::from_rgb(246, 247, 249),
tab_active: Color32::from_rgb(255, 255, 255),
panel: Color32::from_rgb(250, 251, 252),
panel_header: Color32::from_rgb(236, 238, 242),
border: Color32::from_rgb(208, 212, 220),
@@ -212,7 +210,6 @@ impl Tokens {
let mut t = Tokens::light();
t.app_bar = Color32::from_rgb(28, 31, 38);
t.toolbar = Color32::from_rgb(37, 41, 49);
t.tab_active = Color32::from_rgb(46, 52, 64);
t.panel = Color32::from_rgb(39, 43, 52);
t.panel_header = Color32::from_rgb(47, 52, 62);
t.border = Color32::from_rgb(63, 69, 82);
+209 -182
View File
@@ -1,6 +1,6 @@
//! The timeline (bottom): features in order, the rollback marker, errors and warnings.
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
use egui::{Color32, Rect, Sense, Stroke, UiBuilder, pos2, vec2};
use serde_json::json;
use solvecraft_engine::doc::FeatureKind;
@@ -106,195 +106,222 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
x += 24.0;
}
x += 14.0;
let feats: Vec<_> = app
.session
.doc
.features
.iter()
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
.collect();
// The feature whose body is under the cursor in the viewport.
let st = app.session.world_state();
let from_view = match &app.viewport.hover {
Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => {
st.body(body).map(|b| b.feature)
}
_ => None,
};
let drag_id = ui.id().with("tl_drag");
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
let pointer = ui.input(|i| i.pointer.hover_pos());
let mut xs: Vec<f32> = Vec::new();
let mut hovered: Option<u64> = None;
let mut edit: Option<u64> = None;
let mut marker_rect: Option<Rect> = None;
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
if i == marker {
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
x += 10.0;
}
xs.push(x);
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
let res = app.session.model.result(*id);
let err = res.and_then(|r| r.error.clone());
let warn = res.and_then(|r| r.warning.clone());
let rolled = i >= marker;
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
if resp.hovered() {
hovered = Some(*id);
}
let bg = if err.is_some() {
t.timeline_item_error
} else if sel {
t.accent_soft
} else if resp.hovered() || from_view == Some(*id) {
t.hover
} else {
t.timeline_item
};
let edge = if err.is_some() {
t.error
} else if from_view == Some(*id) {
t.accent
} else {
t.border
};
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
if *suppressed {
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
}
if warn.is_some() && err.is_none() {
icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning);
}
let mut tip = format!("{name} ({ty})");
if let Some(e) = &err {
tip += &format!("\nError: {e}");
}
if let Some(w) = &warn {
tip += &format!("\nWarning: {w}");
}
if let Some(r) = res {
tip += &format!("\n{:.1} ms", r.ms);
}
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
if resp.drag_started() {
drag = Some(Drag::Feature(*id));
}
let resp = resp.on_hover_text(tip);
if resp.clicked() {
// A dialog input that takes features (pattern objects) gets the click.
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
if takes {
if let Some(mut d) = app.dialog.take() {
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
app.dialog = Some(d);
// The features scroll horizontally in the space right of the playback controls.
let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| {
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
let p = ui.painter().clone();
let mut x = ui.max_rect().left();
let origin = x;
let feats: Vec<_> = app
.session
.doc
.features
.iter()
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
.collect();
// The feature whose body is under the cursor in the viewport.
let st = app.session.world_state();
let from_view = match &app.viewport.hover {
Some(
crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. },
) => st.body(body).map(|b| b.feature),
_ => None,
};
let drag_id = ui.id().with("tl_drag");
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
let pointer = ui.input(|i| i.pointer.hover_pos());
let mut xs: Vec<f32> = Vec::new();
let mut hovered: Option<u64> = None;
let mut edit: Option<u64> = None;
let mut marker_rect: Option<Rect> = None;
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
if i == marker {
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
x += 10.0;
}
} else {
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
xs.push(x);
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
let res = app.session.model.result(*id);
let err = res.and_then(|r| r.error.clone());
let warn = res.and_then(|r| r.warning.clone());
let rolled = i >= marker;
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
if resp.hovered() {
hovered = Some(*id);
}
let bg = if err.is_some() {
t.timeline_item_error
} else if sel {
t.accent_soft
} else if resp.hovered() || from_view == Some(*id) {
t.hover
} else {
t.timeline_item
};
let edge = if err.is_some() {
t.error
} else if from_view == Some(*id) {
t.accent
} else {
t.border
};
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(
&p,
br.shrink(4.0),
icon,
ink,
if rolled { t.panel_header } else { t.icon_fill },
if rolled { t.border } else { t.accent },
);
if *suppressed {
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
}
if warn.is_some() && err.is_none() {
icons::paint(
&p,
Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)),
"warning",
t.icon,
t.warning,
t.warning,
);
}
let mut tip = format!("{name} ({ty})");
if let Some(e) = &err {
tip += &format!("\nError: {e}");
}
if let Some(w) = &warn {
tip += &format!("\nWarning: {w}");
}
if let Some(r) = res {
tip += &format!("\n{:.1} ms", r.ms);
}
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
if resp.drag_started() {
drag = Some(Drag::Feature(*id));
}
let resp = resp.on_hover_text(tip);
if resp.clicked() {
// A dialog input that takes features (pattern objects) gets the click.
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
if takes {
if let Some(mut d) = app.dialog.take() {
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
app.dialog = Some(d);
}
} else {
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
}
}
if resp.double_clicked() {
edit = Some(*id);
}
let sketch_of = match kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => {
Some(*sketch)
}
_ => None,
};
resp.context_menu(|ui| {
if ui.button("Edit Feature").clicked() {
edit = Some(*id);
ui.close();
}
if let Some(sk) = sketch_of
&& ui.button("Edit Profile Sketch").clicked()
{
app.edit_sketch(sk);
ui.close();
}
if ui.button("Rename").clicked() {
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name));
ui.close();
}
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
let _ = app.run("timeline.suppress", json!({"feature": id}));
ui.close();
}
if ui.button("Roll History Marker Here").clicked() {
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
ui.close();
}
if ui.button("Find in Browser").clicked() {
find_in_browser(app, *id);
ui.close();
}
ui.separator();
if ui.button("Delete").clicked() {
delete_feature(app, *id);
ui.close();
}
});
x += 32.0;
}
}
if resp.double_clicked() {
edit = Some(*id);
}
let sketch_of = match kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch),
_ => None,
};
resp.context_menu(|ui| {
if ui.button("Edit Feature").clicked() {
edit = Some(*id);
ui.close();
let end = x;
if marker >= feats.len() {
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
}
if let Some(sk) = sketch_of
&& ui.button("Edit Profile Sketch").clicked()
// The history marker: drag it to roll the model back or forward.
if let Some(mr) = marker_rect {
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
if resp.drag_started() {
drag = Some(Drag::Marker);
}
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
p.rect_filled(mr, 1.0, col);
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
}
let content_end = crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
// (Taking the drag ends it even when the pointer has left the window.)
if released
&& let Some(dg) = drag.take()
&& let Some(pp) = pointer
{
app.edit_sketch(sk);
ui.close();
let slot = slot_at(&xs, end, pp.x);
match dg {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
}
Drag::Feature(id) => {
let from = app.session.doc.feature_index(id).unwrap_or(0);
// Dropping after itself means the slot shifts by one.
let to = if slot > from { slot - 1 } else { slot };
if to != from {
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
}
}
}
}
if ui.button("Rename").clicked() {
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name));
ui.close();
}
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
let _ = app.run("timeline.suppress", json!({"feature": id}));
ui.close();
}
if ui.button("Roll History Marker Here").clicked() {
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
ui.close();
}
if ui.button("Find in Browser").clicked() {
find_in_browser(app, *id);
ui.close();
}
ui.separator();
if ui.button("Delete").clicked() {
delete_feature(app, *id);
ui.close();
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
}
None => d.remove::<Drag>(drag_id),
});
app.viewport.hover_feature = hovered;
if let Some(id) = edit {
app.edit_feature(id);
}
});
x += 32.0;
}
let end = x;
if marker >= feats.len() {
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
}
// The history marker: drag it to roll the model back or forward.
if let Some(mr) = marker_rect {
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
if resp.drag_started() {
drag = Some(Drag::Marker);
}
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
p.rect_filled(mr, 1.0, col);
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
}
crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
// (Taking the drag ends it even when the pointer has left the window.)
if released
&& let Some(dg) = drag.take()
&& let Some(pp) = pointer
{
let slot = slot_at(&xs, end, pp.x);
match dg {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
}
Drag::Feature(id) => {
let from = app.session.doc.feature_index(id).unwrap_or(0);
// Dropping after itself means the slot shifts by one.
let to = if slot > from { slot - 1 } else { slot };
if to != from {
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
}
}
}
}
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
}
None => d.remove::<Drag>(drag_id),
});
app.viewport.hover_feature = hovered;
if let Some(id) = edit {
app.edit_feature(id);
}
});
}
+1 -2
View File
@@ -38,7 +38,7 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
egui::Panel::top("sc_appbar").exact_size(34.0).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar {
@@ -305,7 +305,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let active = app.ui.tab == tab;
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
if active {
painter.rect_filled(tr, egui::CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_active);
let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c));
} else if resp.hovered() {
+134 -11
View File
@@ -1445,6 +1445,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let proj = projection(app, rect);
// Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused.
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
let navigating =
scroll != 0.0 || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) || app.viewport.spin || app.cam_anim.is_some();
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
let painter = ui.painter_at(rect);
// Background gradient.
let (top, bot) = (t.viewport_top, t.viewport_bottom);
@@ -1505,11 +1513,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// ---- interaction ----
// No picking while the view moves (orbit, pan, zoom): nothing can be hovered meanwhile.
let navigating = scroll != 0.0 || ((middle || secondary_down) && delta != egui::Vec2::ZERO) || (app.viewport.nav.is_some() && primary_down);
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
let add = mods.shift || mods.command || mods.ctrl;
if let Some(p) = hover.filter(|_| inside) {
if app.tool.is_some() {
@@ -2099,6 +2102,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
}
let labels: Vec<String> = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect();
let size = cube_label_size(ui.ctx(), &labels);
// Back to front: the faces and facets turned toward the camera.
for (i, pts) in shapes.iter().rev() {
let tg = &targets[*i];
@@ -2114,7 +2119,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
// Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
if !tg.corner && ink > 0.05 {
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink)));
let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink);
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color));
}
}
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
@@ -2205,10 +2211,24 @@ fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
}
/// A face's label printed on the face: laid out flat, then mapped through the cube's projection,
/// so it turns and foreshortens with the face.
fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(9.5), color, f32::INFINITY));
/// How much of a face the widest face label spans.
const CUBE_LABEL_WIDTH: f32 = 0.9;
/// The face labels' font size: one size for all, the widest of `labels` spanning
/// `CUBE_LABEL_WIDTH` of a face.
fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 {
let probe = 10.0;
let widest = labels
.iter()
.map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x))
.fold(0.0, f32::max);
if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe }
}
/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's
/// projection, so it turns and foreshortens with the face.
fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY));
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
let mut mesh = egui::Mesh::default();
@@ -2404,6 +2424,88 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted();
let _ = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| {
show(app, ui);
},
);
// Click selection is applied after rendering; hover itself must already be current.
if app.viewport.gpu.is_some() && selected == app.highlighted() {
assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover");
}
}
fn hover_app(gpu: bool) -> SolveApp {
let mut s = solvecraft_engine::Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
if gpu {
// The callback is queued by egui; no device is needed to inspect scene updates.
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1 });
}
app
}
#[test]
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
for gpu in [false, true] {
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary, egui::PointerButton::Primary] {
let ctx = egui::Context::default();
let mut app = hover_app(gpu);
if button == egui::PointerButton::Primary {
app.viewport.nav = Some(NavMode::Orbit);
}
hover_frame(&mut app, &ctx, vec![]);
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert!(app.viewport.hover.is_some());
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() }]);
assert!(app.viewport.hover.is_none(), "hover on navigation press");
for offset in [1.0, 2.0, 3.0] {
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(offset, 0.0))]);
assert!(app.viewport.hover.is_none(), "hover while moving");
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none(), "hover between movement events");
}
}
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() }]);
assert!(app.viewport.hover.is_some(), "hover must return on release");
}
}
}
#[test]
fn hover_stays_off_during_spin_and_animated_views() {
let ctx = egui::Context::default();
let mut app = hover_app(true);
hover_frame(&mut app, &ctx, vec![]);
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert!(app.viewport.hover.is_some());
app.viewport.spin = true;
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none());
}
app.viewport.spin = false;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
app.cam_anim = Some(solvecraft_engine::render::CameraAnim::new(app.cam, app.cam, 0.0));
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none());
}
app.cam_anim = None;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
}
fn cylinder_app() -> SolveApp {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
@@ -2741,6 +2843,26 @@ mod tests {
}
}
fn face_labels(targets: &[CubeTarget]) -> Vec<String> {
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
}
/// The widest label spans 90% of its face, looking straight at it.
#[test]
fn view_cube_widest_label_spans_most_of_its_face() {
let ctx = egui::Context::default();
let mut span = 0.0f32;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets()));
let front = Vec3::new(0.0, -1.0, 0.0);
let (r, u, _) = basis_from(front);
let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]);
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
span = (hi - lo) / (2.0 * CUBE_SCALE as f32);
});
assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face");
}
/// From any direction, each label is printed within its face.
#[test]
fn view_cube_labels_stay_on_their_faces() {
@@ -2748,6 +2870,7 @@ mod tests {
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let targets = cube_targets();
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
let c = pos2(100.0, 100.0);
for dir in orbit_directions().step_by(7) {
let (r, u, b) = basis_from(dir);
@@ -2756,7 +2879,7 @@ mod tests {
if tg.corner {
continue;
}
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) {
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) {
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
worst = worst.max(outside);
drawn += 1;
+32
View File
@@ -1,3 +1,35 @@
# Design loading
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
The parse timer includes document validation and repair, not just the JSON parser.
Windows x64, optimized `ci` profile, measured during other builds:
| Design | Parse/repair | Evaluate | Display meshes |
|---|---:|---:|---:|
| 19-feature enclosure, 34 KB | 1.2 ms | 14.9 s | 6 ms |
| 25-feature assembly, 15.2 MB, 2,990 bodies | 21 ms | 12.5 s | 2.1 s |
| Generated 1,000 independent boxes, 185 KB | 5 ms | 0.82 s | 0.15 s |
| Generated 5,000 boxes in five grids, 10 features | 0.45 ms | 17.6 s | 0.81 s |
The enclosures spend most of evaluation in joins and cuts, not parsing. Designs save feature
definitions; opening them rebuilds their geometry. Those times remain after the repair fix.
A separate generated timeline with missing input names reproduced minutes in the reader:
| Timeline (geometry rolled back) | Parse/repair before | After |
|---|---:|---:|
| 2,000 boxes, 6,000 unnamed inputs | 78.1 s | 10–20 ms |
| 5,000 boxes, 15,000 unnamed inputs | interrupted after 131 s, still repairing | 26–46 ms |
Previously, each missing input restarted the `d1`, `d2`, … search, scanning the document for
every candidate. This approaches cubic work as unnamed inputs accumulate. Loading now reserves
names once and assigns missing names with an advancing cursor. Existing names, names on later
features, parameter collisions and reuse of surplus names retain the previous behavior.
Regression tests compare the old and bulk repairs and load a 5,000-feature unnamed timeline.
# Viewport performance
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which
+21
View File
@@ -6,6 +6,27 @@ GitHub Release. Publishing that verified draft is a separate operation. Pushing
pushing a tag does not start the release workflow. There is no automatic commit-message-based
version bump and no patch release for every merge.
## Nightly prereleases
`release.yml` also runs at midnight in `America/New_York` (Eastern time, following daylight
saving time). GitHub may start scheduled runs later when its runners are busy.
Before building, it compares the exact scheduled `main` commit with the tag of the most
recently published release, including prereleases. If the commits match, all packaging and
publication jobs are skipped: no new version, tag, or release is created. Drafts do not count
as published releases, so a failed build or publication can be retried the following night.
Changed commits use the existing package build and validation jobs. Only after all jobs
succeed, the workflow stages the assets in a draft and publishes a prerelease named
`v<workspace-version>-nightly.<YYYYMMDD>.<run-number>.<run-attempt>` with checksums and generated
notes. Any existing workspace prerelease suffix is replaced by the nightly suffix. Dates
use Eastern time; package versions are overridden without changing Cargo.toml or Cargo.lock,
so the binaries' embedded Cargo version remains the workspace version. Nightlies do not
replace the latest stable release or advance the `release` branch. The normal deliberate
release process below continues to create drafts for separate verification and publication.
Nightly publication uses `GITHUB_TOKEN`, so it does not trigger the downstream Flatpak Pages
deployment. The stable Flatpak repository is not automatically updated with nightly builds.
## Choose and prepare the version
1. Fetch `origin` and its tags. Inspect the previous published release and the changes since
@@ -0,0 +1,24 @@
{
"description": "A loft cut from a pocket floor beside a round hole it never touches (the hole made first). Slab 900 x 480 x 160, a 557.68 x 100 pocket 120 deep, a 35 mm hole 25 deep 66 mm from the pocket, then a loft cut from a rectangle on the pocket floor (0.5 mm inside its walls) down 4 mm to an 80 x 80 square. The loft's top lies in the pocket floor, and the hole's cylinder kept the exact planar boolean from taking it: the cut failed with a kernel panic. Result: the same body as with the hole made after the loft.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "offset": 690, "name": "Outline"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, -480], "p1": [900, 0]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "BowlTop"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.16, -151.16], "p1": [728.84, -51.16]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": -120, "operation": "cut", "name": "BowlPocket"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "TapHole"}},
{"command": "sketch.circle.center", "params": {"center": [795, -101.16], "radius": 17.5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": -25, "operation": "cut", "name": "TapCut"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 730, "name": "TroughInset"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, -150.66], "p1": [728.34, -51.66]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 726, "name": "DrainSeat"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, -141.16], "p1": [490, -61.16]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.loft", "params": {"sections": [{"sketch": "TroughInset"}, {"sketch": "DrainSeat"}], "operation": "cut", "name": "TroughFall"}}
]
}
@@ -0,0 +1,15 @@
{
"description": "A thin 437 mm bar whose profile has a 3 mm concave arc, cut by a long prism whose top is tilted 21 degrees to the bar (an elliptical cut through the arc's cylinder). The check on the result meshed both bodies at a step from the 920 mm prism, which faceted the arc so coarsely that a correct cut failed the membership test. Result: the bar with its end trimmed by the tilted plane, as a split by that plane gives.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
{"command": "sketch.line", "params": {"points": [[-472, 853.91], [-432, 838.47], [-432, 820], [-472, 820]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
]
}
@@ -0,0 +1,15 @@
{
"description": "The oblique cut of oblique_cut_of_cylinder.json with the prism running past the bar's end. Moving a boolean result back after a shifted retry computed a matrix norm whose root refinement never ended: the app hung at full CPU. Result: the same trimmed bar.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
{"command": "sketch.line", "params": {"points": [[-480, 856.9979999999999], [-432, 838.47], [-432, 820], [-480, 820]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
]
}
@@ -0,0 +1,16 @@
{
"description": "A shallow, wide loft cut starting in a body's top face: slab 900 x 480 x 160, loft cut from a 556.68 x 99 rectangle on the top face down 4 mm to an 80 x 80 square (sides about 0.9 degrees from horizontal). Healing an intermediate boolean result panicked outside the boolean's guard and stopped every later attempt. Result: the slab less the loft, 69 003 015.31 mm³.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [900, 480]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 160, "name": "LoftTop"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, 329.34], "p1": [728.34, 428.34]}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": 156, "name": "LoftBottom"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, 338.84], "p1": [490, 418.84]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.loft", "params": {"sections": [{"sketch": "LoftTop"}, {"sketch": "LoftBottom"}], "operation": "cut", "name": "LoftCut"}}
]
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Decide before building or assigning a nightly version. Checkout must include all tags.
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != schedule ]; then
echo 'build=true' >>"$GITHUB_OUTPUT"
exit 0
fi
# /releases/latest excludes prereleases. Include every published release instead,
# including nightlies, and sort by publication time across all API pages.
latest_tag="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq '[.[][] | select(.draft == false and .published_at != null)] | sort_by(.published_at) | last | .tag_name // empty')"
if [ -n "$latest_tag" ]; then
# Resolve the tag itself, not target_commitish (which can be a moving branch).
released_sha="$(git rev-parse --verify "refs/tags/$latest_tag^{commit}")"
if [ "$released_sha" = "$GITHUB_SHA" ]; then
echo 'build=false' >>"$GITHUB_OUTPUT"
echo "### Nightly skipped: no new commits since $latest_tag" >>"$GITHUB_STEP_SUMMARY"
exit 0
fi
fi
echo 'build=true' >>"$GITHUB_OUTPUT"
+12
View File
@@ -49,3 +49,15 @@ Changes:
direction (long triangles from a trim loop to the grid's far ends cut through cylinders).
- `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are
dropped (their sources are not vendored).
## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath)
truck-geometry uses it for a matrix's operator norm (an intersection curve scales its tolerance
by it whenever it is moved).
Changes:
- `solver.rs`: the Newton refinement of cubic and quartic roots stops after 100 steps. Near a
double root it could cycle forever: moving a body whose edges a boolean made (the retries
move both bodies and the result back) hung the app.
- `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources
are not vendored).
+40
View File
@@ -0,0 +1,40 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "matext4cgmath"
version = "0.1.0"
authors = ["Yoshinori Tanimura <[email protected]>"]
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"
+201
View File
@@ -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.
+5
View File
@@ -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.
+153
View File
@@ -0,0 +1,153 @@
use crate::*;
impl<F: BaseFloat> EigenValues for Matrix2<F> {
type EigenValues = [Complex<F>; 2];
fn eigenvalues(self) -> [Complex<F>; 2] {
solver::solve_quadratic(-self.trace(), self.determinant())
}
}
impl<F: BaseFloat> EigenValues for Matrix3<F> {
type EigenValues = [Complex<F>; 3];
fn eigenvalues(self) -> [Complex<F>; 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<F: BaseFloat> EigenValues for Matrix4<F> {
type EigenValues = [Complex<F>; 4];
fn eigenvalues(self) -> [Complex<F>; 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<F: BaseFloat> OperatorNorm for Matrix2<F> {
#[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<F: BaseFloat> OperatorNorm for Matrix3<F> {
#[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<F: BaseFloat> OperatorNorm for Matrix4<F> {
#[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,
))
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::*;
impl<F: BaseFloat> Exponential for Matrix2<F> {}
impl<F: BaseFloat> Exponential for Matrix3<F> {}
impl<F: BaseFloat> Exponential for Matrix4<F> {}
impl<F: BaseFloat> Decomposition for Matrix2<F> {
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<F: BaseFloat> Decomposition for Matrix3<F> {
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<F: BaseFloat> Decomposition for Matrix4<F> {
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
}
}
+120
View File
@@ -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<Self> + 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 = <Self::Scalar as Float>::epsilon();
let mut a = self;
let mut res = Self::one();
for i in 2_u16..=64 {
res += a;
a = a * self / <Self::Scalar as NumCast>::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)>;
}
+246
View File
@@ -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<F: BaseFloat>(a: F, b: F) -> [Complex<F>; 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<F: BaseFloat>(p: F, q: F) -> [Complex<F>; 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<F: BaseFloat>(a: F, b: F, c: F) -> [Complex<F>; 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<F: BaseFloat>(p: F, q: F, r: F) -> [Complex<F>; 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<F: BaseFloat>(a: F, b: F, c: F, d: F) -> [Complex<F>; 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
}