Compare commits

...
10 Commits
Author SHA1 Message Date
bherbruck 74fed469dc fix: deploy docs web and Flatpak through one Pages workflow 2026-10-10 21:59:18 -04:00
bherbruck efa6706226 Release SolveCraft 0.1.6 2026-10-10 21:43:21 -04:00
bherbruck c1b6478ab5 fix: join additive extrusions and merge coplanar faces 2026-10-10 21:31:19 -04:00
bherbruck 17e8e3eff2 Merge pull request #109 from CypherNaught-0x/fix/timeline-modifier-highlighting
Highlight fillets and other modifier faces from the timeline
2026-10-10 20:56:59 -04:00
bherbruck 4b7e1d317d fix: load designs off-thread and cache recent thumbnails as PNGs 2026-10-10 20:55:14 -04:00
bherbruck b375c2a92a Merge pull request #108 from CypherNaught-0x/fix/basin-rim-blends
Fix fillets and chamfers around sloped pocket rims
2026-10-10 20:05:45 -04:00
CypherNaugh_0x 5275911b44 Highlight modifier faces from timeline hover and selection 2026-10-11 01:59:02 +02:00
95ae096265 Restore minimal view-cube turns and drag-to-orbit
Recover N3MI-DG's TOP/BOTTOM heading and drag-to-orbit contributions
from PR #101, adapted to the existing 14-target cube. Add Y-up/Z-up
regressions, navigation ownership and dialog blocking coverage.

Original heading commit: c28e780f52
Original orbit commit: f30b30be6e
Source: https://github.com/bherbruck/solvecraft/pull/101

Co-authored-by: N3MI-DG <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 19:50:31 -04:00
CypherNaugh_0x 5835bb10d4 Join chamfers across unequal sloped rim walls 2026-10-11 01:37:06 +02:00
CypherNaugh_0x db4f885bac Support fillets around sloped pocket rims 2026-10-11 01:37:06 +02:00
46 changed files with 1593 additions and 259 deletions

No files matched your search

+10 -134
View File
@@ -12,150 +12,26 @@ on:
permissions:
contents: read
actions: write
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
publish:
name: Request the combined docs, web, and Flatpak deployment
runs-on: ubuntu-24.04
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
- name: Deploy through the shared Pages workflow
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(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-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/solvecraft-$version-linux-$arch.flatpak"
done
flatpak build-update-repo \
--title="SolveCraft" \
--comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
test "$published" = "$RELEASE_TAG"
gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
-f release_tag="$RELEASE_TAG"
echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
+17
View File
@@ -7,6 +7,12 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
release_tag:
description: "Published release for Flatpak (empty: latest stable release)"
type: string
required: false
default: ""
permissions:
contents: read
@@ -50,6 +56,17 @@ jobs:
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
id: pages
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Include the published Flatpak repository
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag || '' }}
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: bash packaging/flatpak-site.sh site
- uses: actions/upload-pages-artifact@v3
with:
path: site
+6
View File
@@ -42,6 +42,12 @@ Generated-in-code assets are original and have no file to list:
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Recovered contributions
| SolveCraft file | Original contribution | Licence |
|---|---|---|
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `view_cube_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the 14-target cube | MIT OR Apache-2.0 |
## Vendored third-party code
| Directory | Source | Licence | Changes |
Generated
+13 -13
View File
@@ -3057,7 +3057,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"eframe",
"egui",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3080,7 +3080,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"log",
"serde",
@@ -3094,7 +3094,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"log",
"serde",
@@ -3110,7 +3110,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"serde",
"serde_json",
@@ -3118,7 +3118,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"log",
"serde",
@@ -3145,7 +3145,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3153,7 +3153,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"image",
"serde",
@@ -3162,7 +3162,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"serde",
"serde_json",
@@ -3173,7 +3173,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"egui",
"egui-wgpu",
@@ -3187,7 +3187,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"eframe",
"egui",
@@ -4486,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.5"
version = "0.1.6"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+5 -2
View File
@@ -51,7 +51,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
@@ -67,7 +67,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
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 (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
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
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;
+14 -5
View File
@@ -3,7 +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 bench-open <design.solvecraft | part.step> [--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
@@ -32,7 +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 bench-open <design.solvecraft | part.step> [--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
@@ -357,17 +357,26 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
/// 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 path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--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 {
let step = solvecraft_engine::io::is_step_path(path);
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
if len > limit 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 doc = if step {
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
doc
} else {
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") {
+1 -1
View File
@@ -191,7 +191,7 @@ fn main() -> eframe::Result {
app.session.undo.clear();
}
for f in &files {
app.open_path(f);
app.open_path_async(f);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
+9 -3
View File
@@ -19,7 +19,9 @@ pub struct ModelBody {
pub body: Body,
/// Feature that created it.
pub feature: u64,
mesh: OnceLock<Arc<Mesh>>,
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
// meshing any snapshot also warms every unchanged copy of this body.
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// Identity of this body's content for the evaluation cache: the key of the evaluation
/// that produced it (kept while later features pass it through unchanged).
key: u64,
@@ -33,7 +35,7 @@ pub struct ModelBody {
impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self {
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0, imported: None }
ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None }
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> {
@@ -2946,7 +2948,11 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
out.bodies.push(ModelBody::new(name, body, b.feature));
let mut placed = ModelBody::new(name, body, b.feature);
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
out.bodies.push(placed);
}
if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
+7
View File
@@ -173,6 +173,13 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
/// Work out a body's face names from the feature that made it and the model before it.
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
// A rigid Move changes coordinates, not topology or face ownership. Geometric
// overlap with the unmoved input cannot recover the names at the new position.
if matches!(o.feature.kind, FeatureKind::Move { .. })
&& let Some(input) = o.before.body(&b.name)
{
return face_names(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
let tol = (size * 1e-4).max(1e-6);
+9
View File
@@ -357,3 +357,12 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() {
);
}
}
#[test]
fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
let a = crate::eval::ModelBody::new("box".into(), body, 1);
let b = a.clone();
assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh()));
assert!(!a.mesh().triangles.is_empty());
}
+17 -14
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: 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?"),
.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, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -141,7 +141,8 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
}
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
/// into a body, `join` when it grows out of one, `new` otherwise.
/// into a body, otherwise `join` when the active component has a solid. Keeping an
/// addition separate requires an explicit `new` operation.
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
@@ -154,23 +155,25 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
}
// Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants");
let inside = |q: Vec3| {
st.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
.any(|b| b.mesh().contains(q))
};
let eps = (d.abs() * 1e-3).max(1e-3);
let sign = if d < 0.0 { -1.0 } else { 1.0 };
let bodies: Vec<_> = st
.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
.collect();
if bodies.is_empty() {
return Some("new");
}
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
if symmetric {
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "new" });
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" });
}
if inside(base + n * (d * 0.5)) {
Some("cut")
} else if inside(base - n * (eps * sign)) {
Some("join")
} else {
Some("new")
// A profile can meet material along its side, or only around part of a
// screw boss. A single point behind its centre cannot establish contact.
// Default additions to Join and let the boolean merge the actual geometry.
Some("join")
}
}
+36
View File
@@ -439,6 +439,42 @@ impl Session {
}
}
/// Refresh UI caches when adopting a session or switching tabs, without discarding its
/// already prepared world-space geometry. This does not change the document.
pub fn invalidate_view(&mut self, previous_revision: u64) {
let old = self.revision;
self.revision = old.max(previous_revision) + 1;
if let Ok(mut cache) = self.world_cache.lock()
&& let Some((revision, _)) = cache.as_mut()
&& *revision == old
{
*revision = self.revision;
}
}
/// Commit commands evaluated on a scratch copy without losing this session's file,
/// saved state, view state, checkpoints or earlier undo steps.
pub fn accept_computed(&mut self, mut computed: Session) {
let previous_revision = self.revision;
self.doc = computed.doc;
self.model = computed.model;
self.world_cache = computed.world_cache;
self.undo.append(&mut computed.undo);
if self.undo.len() > MAX_UNDO {
self.undo.drain(..self.undo.len() - MAX_UNDO);
}
self.redo = computed.redo;
self.active_sketch = computed.active_sketch;
self.selection = computed.selection;
self.revision = computed.revision;
self.invalidate_view(previous_revision);
self.active_component = computed.active_component;
self.pending_moves = computed.pending_moves;
for message in computed.log {
self.echo(message);
}
}
/// Live preview: run `commands` (id, parameters) on a scratch copy and return the model
/// before and after. Nothing in this session changes (document, undo history, selection).
/// A `timeline.redefine` is shown even while the timeline is rolled back to the feature
+99 -3
View File
@@ -592,7 +592,7 @@ fn preview_of_an_edit() {
assert!(Arc::ptr_eq(&doc, &s.doc));
}
/// `operation: "auto"`: into a body cuts, out of a body joins, in free space makes a new body.
/// `operation: "auto"`: into a body cuts; additions default to Join when a solid exists.
#[test]
fn extrude_auto_operation() {
let mut s = Session::default();
@@ -605,7 +605,7 @@ fn extrude_auto_operation() {
// Pulled out, it joins.
assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join"));
// A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a
// profile beside the box makes a new body.
// profile beside the box still defaults to Join (New Body remains explicit).
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [60, 5], "p1": [70, 15]}));
@@ -613,11 +613,70 @@ fn extrude_auto_operation() {
let at = |x: f64, d: f64| json!({"profiles": [{"point": [x, 10]}], "distance": d});
assert_eq!(auto_operation(&s, &at(10.0, 8.0)), Some("cut"));
assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join"));
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("new"));
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("join"));
run(&mut s, "solid.extrude", json!({"profiles": [{"point": [10, 10]}], "distance": 8, "operation": "auto"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6);
}
/// Additive extrusions default to joining even when only a profile side touches material.
#[test]
fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() {
for operation in ["auto", "new"] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 20], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
// The profile centre is outside the vertical arm. Contact is along its
// y=10 side, not behind the centre along the extrusion's X direction.
let p = json!({"distance": 10, "to": [0, 15, 25], "operation": operation});
assert_eq!(auto_operation(&s, &p), Some("join"));
run(&mut s, "solid.extrude", p);
assert!((volume(&mut s) - 5000.0).abs() < 1e-6);
let st = s.model.state();
assert_eq!(st.bodies.len(), if operation == "auto" { 1 } else { 2 });
if operation == "auto" {
let body = &st.bodies.first().unwrap().body;
assert!(body.validity().is_empty());
assert_eq!(body.lumps().unwrap().len(), 1);
assert_eq!(body.face_count(), 8, "the two flat elbow sides must be single faces");
assert!(body.contains(geom::Vec3::new(5.0, 10.0, 25.0)));
}
}
}
#[test]
fn auto_extrude_joins_corner_screw_boss() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1}));
run(&mut s, "sketch.finish", json!({}));
let p = json!({"profiles": [{"point": [12, 10]}], "distance": 5, "operation": "auto"});
assert_eq!(auto_operation(&s, &p), Some("join"));
run(&mut s, "solid.extrude", p);
assert!(rel(volume(&mut s), 3000.0 + PI * 15.0 * 5.0) < 1e-3);
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
let body = &st.bodies.first().unwrap().body;
assert_eq!(body.lumps().unwrap().len(), 1);
assert!(body.validity().is_empty());
assert!(!body.contains(geom::Vec3::new(10.0, 10.0, 32.0)), "the screw hole stays open");
}
#[test]
fn auto_extrude_starts_new_body_in_empty_component() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(auto_operation(&s, &json!({"distance": 5})), Some("new"));
assert_eq!(auto_operation(&s, &json!({"distance": 5, "participants": ["missing"]})), Some("new"));
}
/// Measuring picked geometry: distance between faces, angle, edge length.
#[test]
fn measure_items() {
@@ -2032,3 +2091,40 @@ fn drawing_on_existing_points_connects_to_them() {
let si = run(&mut s, "sketch.inspect", json!({}));
assert_ne!(si["status"], "conflict", "{si}");
}
#[test]
fn computed_import_preserves_file_and_earlier_undo_history() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
s.path = Some("existing.solvecraft".into());
s.mark_saved();
let saved = s.doc.clone();
let mut computed = s.scratch();
run(&mut computed, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0]}));
s.accept_computed(computed);
assert_eq!(s.path.as_deref(), Some("existing.solvecraft"));
assert!(s.is_dirty());
assert_eq!(s.model.state().bodies.len(), 2);
run(&mut s, "edit.undo", json!({}));
assert!(std::sync::Arc::ptr_eq(&saved, &s.doc));
assert!(!s.is_dirty());
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().bodies.is_empty());
}
#[test]
fn adopting_a_computed_component_keeps_its_warm_world_geometry() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
let mut computed = s.scratch();
run(&mut computed, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[30,0,0]}));
let prepared = computed.world_state();
for b in &prepared.bodies {
let _ = b.mesh();
}
s.accept_computed(computed);
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
s.invalidate_view(100);
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
}
+11 -3
View File
@@ -134,7 +134,7 @@ 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 {
pub(crate) fn 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.
@@ -260,7 +260,7 @@ fn blend_geometry(
// 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)))
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol)))
{
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
@@ -785,6 +785,14 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
return Ok(r);
}
}
// At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild
// the corners locally, allowing adjacent chamfers to reach different depths
// on their common wall seam.
if edges.len() > 1
&& let Ok(result) = crate::blend_corner::chamfer(body, edges, distance)
{
return Ok(result);
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
if let Some(groups) = loop_groups(&cur, edges)? {
@@ -961,7 +969,7 @@ mod tests {
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);
let fits = |y| 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
+163 -21
View File
@@ -1,7 +1,7 @@
//! Fillets with a radius per edge, and the corners where filleted edges meet.
//!
//! The edges are straight and convex, and every face at their end vertices is planar with three
//! edges meeting at each such vertex (box-like corners). The faces there are rebuilt around the
//! edges meeting at each such vertex, including reflex corners of pocket rims. The faces there are rebuilt around the
//! blends, faces away from them are kept. At a vertex:
//! - one filleted edge ends on the third face: the blend ends in that face's section of the
//! cylinder (an exact arc or ellipse);
@@ -38,7 +38,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
let mut first: Option<KernelError> = None;
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
for _ in 0..4 {
let err = match build(&cur, &sel) {
let err = match build(&cur, &sel, true) {
Ok(b) => return Ok(b),
Err(e) => e,
};
@@ -54,6 +54,14 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
Err(first.unwrap_or_else(|| unsupported("these fillets")))
}
/// Local equal-distance chamfers, including pocket rims where adjacent walls have
/// different slopes. The planar strips trim each other at each selected corner.
pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect();
build(&cur, &selected, false)
}
/// A face of the shell being rebuilt, or a new face.
#[derive(Clone, Copy, PartialEq)]
enum Owner {
@@ -112,7 +120,7 @@ fn edge(a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve) -> Result<mt::Edge> {
/// along `u`.
fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
let k = u.dot(t);
if k < 1e-6 {
if k.abs() < 1e-6 {
return Err(unsupported("a fillet that runs along its neighbouring edge"));
}
Ok(v + u * (s / k))
@@ -122,8 +130,8 @@ fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
/// directions `t1`, `t2`, setbacks `s1`, `s2`) cross.
fn meet(v: Vec3, (u1, t1, s1): (Vec3, Vec3, f64), (u2, t2, s2): (Vec3, Vec3, f64)) -> Result<Vec3> {
let (k1, k2) = (u1.dot(t2), u2.dot(t1));
if k1 < 1e-6 || k2 < 1e-6 {
return Err(unsupported("fillets of edges that meet at a reflex corner"));
if k1.abs() < 1e-6 || k2.abs() < 1e-6 {
return Err(unsupported("fillets of nearly parallel edges at a corner"));
}
Ok(v + u1 * (s2 / k1) + u2 * (s1 / k2))
}
@@ -174,7 +182,7 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec<Owner>)],
Err(KernelError::Failed("fillet corner: a boundary does not close".into()))
}
fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy();
@@ -252,16 +260,20 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
return Err(unsupported("the faces at the edge are tangent"));
}
let r = radius.get(id).copied().unwrap_or(1.0);
let s = r / (alpha / 2.0).tan();
let s = if round { r / (alpha / 2.0).tan() } else { r };
let w = (alpha / 2.0).sin();
// The cylinder, well past both ends (sections on oblique end faces reach beyond them).
let m = 4.0 * (s + r);
let (q0, q1) = (p0 - d * m, p1 + d * m);
let ctrl = vec![vec![h(q0 + ta * s, 1.0), h(q1 + ta * s, 1.0)], vec![h(q0, w), h(q1, w)], vec![h(q0 + tb * s, 1.0), h(q1 + tb * s, 1.0)]];
let surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
ctrl,
)));
let surf = if round {
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
ctrl,
)))
} else {
mt::Surface::Plane(mt::Plane::new(p3(q0 + ta * s), p3(q1 + ta * s), p3(q0 + tb * s)))
};
let mid = (p0 + p1) * 0.5;
let axis = mid + ta * s - na * r;
let out = ((ta + tb) * -1.0).normalized().unwrap_or(na);
@@ -311,20 +323,33 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
let rest: Vec<(mt::EdgeID, Vec3)> = ar.edges.iter().filter(|(id, _)| !sels.contains_key(id)).copied().collect();
chosen.sort_by_key(|(id, _)| order.iter().position(|x| x == id));
let sel = |id: mt::EdgeID| sels.get(&id).ok_or_else(|| KernelError::Failed("fillet".into()));
// At an inner rim a contact line can extend the next edge backwards.
// Its new endpoint must remain in the trimmed face, including its holes.
let check_extension = |q: Vec3, fi: usize| -> Result<Vec3> {
let face = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
if !crate::blend::extension_fits(face, v, q, size * 1e-7) {
return Err(unsupported("a fillet larger than the faces next to it"));
}
Ok(q)
};
let hit = |u: Vec3, t: Vec3, s: f64, fi: usize| -> Result<Vec3> {
let q = hit(v, u, t, s)?;
if u.dot(t) < 0.0 { check_extension(q, fi) } else { Ok(q) }
};
let list = pool.entry(*vid).or_default();
match (&chosen[..], &rest[..]) {
([(e1, _)], [(e2, u2), (e3, u3)]) => {
let s1 = sel(*e1)?;
let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?);
let f23 = face_with(*e2, *e3)?;
let p2 = hit(v, *u2, s1.t(f12), s1.s)?;
let p3_ = hit(v, *u3, s1.t(f31), s1.s)?;
let p2 = hit(*u2, s1.t(f12), s1.s, f12)?;
let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?;
let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_)));
endpt.insert((*e2, *vid, None), a.clone());
endpt.insert((*e1, *vid, Some(f12)), a.clone());
endpt.insert((*e3, *vid, None), b.clone());
endpt.insert((*e1, *vid, Some(f31)), b.clone());
let c = edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))?;
let c = if round { edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))? } else { builder::line(&a, &b) };
list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)]));
}
([(ea, ua), (eb, ub)], [(e3, u3)]) => {
@@ -332,14 +357,19 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
let fab = face_with(*ea, *eb)?;
let (fb3, fa3) = (face_with(*eb, *e3)?, face_with(*ea, *e3)?);
let q = meet(v, (*ua, sa.t(fab), sa.s), (*ub, sb.t(fab), sb.s))?;
if ua.dot(sb.t(fab)) < 0.0 || ub.dot(sa.t(fab)) < 0.0 {
check_extension(q, fab)?;
}
// Where each cylinder touches the third edge.
let xb = hit(v, *u3, sb.t(fb3), sb.s)?;
let xa = hit(v, *u3, sa.t(fa3), sa.s)?;
let xb = hit(*u3, sb.t(fb3), sb.s, fb3)?;
let xa = hit(*u3, sa.t(fa3), sa.s, fa3)?;
let qv = builder::vertex(p3(q));
endpt.insert((*ea, *vid, Some(fab)), qv.clone());
endpt.insert((*eb, *vid, Some(fab)), qv.clone());
let (la, lb) = ((xa - v).len(), (xb - v).len());
let crease_to = |end: Vec3| -> Result<mt::Curve> { crease(sa, sb, q, end, size) };
let crease_to = |end: Vec3| -> Result<mt::Curve> {
if round { crease(sa, sb, q, end, size) } else { Ok(mt::Curve::Line(mt::Line(p3(q), p3(end)))) }
};
if (la - lb).abs() <= size * 1e-9 {
let xv = builder::vertex(p3(xb));
endpt.insert((*e3, *vid, None), xv.clone());
@@ -354,16 +384,22 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
if lb > la { (sb, sa, *ua, fb3, fa3, *eb, *ea, xb) } else { (sa, sb, *ub, fa3, fb3, *ea, *eb, xa) };
// The big cylinder's section by the small one's end face: from the third
// edge to the small edge's line, where it crosses the small contact line.
let z = hit(v, us, big.t(fab), big.s)?;
let z = hit(us, big.t(fab), big.s, fab)?;
let mut sec = conic(x, v, z, big.w);
let tt = small.t(fsmall3);
let c0 = (x - v).dot(tt) - small.s;
let c1 = big.w * (0.0 - small.s);
let c2 = (z - v).dot(tt) - small.s;
let t = bernstein_root(c0, c1, c2).ok_or_else(|| unsupported("fillets this different in size at one corner"))?;
let y = {
// A chamfer section is a line, not the cylinder's conic.
// Where it meets the other strip's contact line is also
// where the crease ends; the remainder trims that wall.
let t = if round { bernstein_root(c0, c1, c2) } else { Some(c0 / (c0 - c2)).filter(|t| *t > 0.0 && *t < 1.0) }
.ok_or_else(|| unsupported("blends this different in size at one corner"))?;
let y = if round {
use mt::ParametricCurve;
crate::body::from_p3(sec.subs(t))
} else {
x + (z - x) * t
};
{
use mt::Cut;
@@ -374,13 +410,16 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
endpt.insert((*e3, *vid, None), xv.clone());
endpt.insert((eb_, *vid, Some(fbig3)), xv.clone());
endpt.insert((es, *vid, Some(fsmall3)), yv.clone());
let piece = edge(&xv, &yv, mt::Curve::NurbsCurve(sec))?;
let piece = if round { edge(&xv, &yv, mt::Curve::NurbsCurve(sec))? } else { builder::line(&xv, &yv) };
list.push((piece, vec![Owner::Face(fsmall3), Owner::Cyl(eb_)]));
let c = edge(&qv, &yv, crease_to(y)?)?;
list.push((c, vec![Owner::Cyl(*ea), Owner::Cyl(*eb)]));
}
}
([(e1, u1), (e2, u2), (e3, u3)], []) => {
if !round {
return Err(unsupported("three chamfers at one corner in the local blend"));
}
let (s1, s2, s3) = (sel(*e1)?, sel(*e2)?, sel(*e3)?);
let (f12, f23, f31) = (face_with(*e1, *e2)?, face_with(*e2, *e3)?, face_with(*e3, *e1)?);
let q12 = meet(v, (*u1, s1.t(f12), s1.s), (*u2, s2.t(f12), s2.s))?;
@@ -1086,4 +1125,107 @@ mod tests {
.unwrap();
assert!(fillet_radii(&l, &[(Vec3::new(10.0, 10.0, 10.0), 1.0), (c, 2.0)]).is_err());
}
#[test]
fn sloped_basin_rim_fillets() {
use solvecraft_geom::{Loop2, Plane, Vec2};
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 slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let pocket =
crate::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 = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
let picks = [Vec3::new(50.0, 4.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
for count in 1..=4 {
let result = fillet(&basin, &picks[..count], 2.0).unwrap();
assert!(result.validity().is_empty());
assert_eq!(result.unmeshed_faces(), 0);
assert!(vol(&result) < vol(&basin));
let mesh = result.tessellate(0.01).unwrap();
assert!(mesh.contains(Vec3::new(50.0, 2.0, 27.0)));
assert!(!mesh.contains(Vec3::new(50.0, 3.9, 29.99)));
// The small blend removes only a strip at the rim, and never escapes
// the slab. Topology checks alone do not catch crossed faces.
assert!(vol(&basin) - vol(&result) < 2.0_f64.powi(2) * (60.0 + 46.0) * 2.0);
for v in result.solid.vertex_iter() {
let p = vtx(&v);
assert!(p.x >= 0.0 && p.x <= 100.0 && p.y >= 0.0 && p.y <= 60.0 && p.z >= 0.0 && p.z <= 30.0 + 1e-8);
}
}
}
#[test]
fn sloped_rim_fillets_cannot_extend_outside_the_material() {
use solvecraft_geom::{Loop2, Plane, Vec2};
for margin in [0.5, 1.0] {
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 slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let pocket = crate::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 = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
for count in [1, 2, 4] {
assert!(fillet(&basin, &picks[..count], 4.0).is_err());
assert!(fillet(&basin, &picks[..count], margin * 0.5).is_ok());
}
}
}
#[test]
fn chamfers_meet_on_unequal_sloped_rim_walls() {
use solvecraft_geom::{Loop2, Plane, Vec2};
for margin in [1.0, 4.0] {
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 slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let pocket = crate::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 = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
for count in [2, 3, 4] {
if margin == 1.0 {
assert!(crate::chamfer(&basin, &picks[..count], 2.0).is_err());
assert!(crate::chamfer(&basin, &picks[..count], 1.0).is_err());
}
let distance = if margin == 1.0 { 0.5 } else { 2.0 };
let result = crate::chamfer(&basin, &picks[..count], distance).unwrap();
assert!(result.validity().is_empty());
assert_eq!(result.unmeshed_faces(), 0);
assert!(vol(&result) < vol(&basin));
assert!(vol(&basin) - vol(&result) < distance.powi(2) * (60.0 + 50.0 - margin));
let reverse: Vec<_> = picks[..count].iter().copied().rev().collect();
let reversed = crate::chamfer(&basin, &reverse, distance).unwrap();
assert!((vol(&result) - vol(&reversed)).abs() < 1e-6);
// These are exact planes, with every boundary on its owning face.
for f in result.solid.face_iter() {
let Some(n) = plane_normal(f) else { continue };
let origin = vtx(&f.vertex_iter().next().unwrap());
for v in f.vertex_iter() {
assert!((vtx(&v) - origin).dot(n).abs() < 1e-7);
}
}
}
}
}
#[test]
fn equal_slope_rim_loops_cannot_escape_the_face() {
use solvecraft_geom::{Loop2, Plane, Vec2};
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 slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let pocket =
crate::loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 1.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 11.0, 70.0, 40.0))])
.unwrap();
let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
let picks = [Vec3::new(50.0, 1.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
assert!(crate::chamfer(&basin, &picks, 2.0).is_err());
assert!(fillet(&basin, &picks, 4.0).is_err());
assert!(crate::chamfer(&basin, &picks, 0.5).is_ok());
assert!(fillet(&basin, &picks, 0.5).is_ok());
}
}
+24 -3
View File
@@ -13,6 +13,8 @@ pub(crate) type Solid = mt::Solid;
#[derive(Clone, Debug)]
pub struct Body {
pub(crate) solid: std::sync::Arc<Solid>,
/// Missing-face check from the orientation mesh, reusable at exactly its tolerance.
pub(crate) coarse_missing: Option<(f64, usize)>,
/// Set for mesh bodies (`solid` is then empty).
pub(crate) mesh: Option<std::sync::Arc<crate::meshbody::TriMesh>>,
/// Display colour (linear RGB 0..1) from an imported file; operations drop it.
@@ -302,11 +304,24 @@ impl Body {
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
};
let tol = (span * 0.01).clamp(1e-3, 1.0);
let v = guard("orient", || Ok(mesh_shells(&solid, tol).iter().map(|s| s.to_polygon().volume()).sum::<f64>()))?;
let (v, missing) = guard("orient", || {
let shells = mesh_shells(&solid, tol);
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
let missing =
shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count();
Ok((volume, missing))
})?;
if v < 0.0 {
solid.not();
}
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None })
Ok(Body {
solid: std::sync::Arc::new(solid),
coarse_missing: (v >= 0.0).then_some((tol, missing)),
mesh: None,
color: None,
paint: None,
splits: None,
})
}
/// A deep copy of the solid that can be mutated without affecting other bodies.
@@ -506,7 +521,13 @@ impl Body {
if self.is_mesh() {
return 0;
}
let Ok(m) = self.tessellate((self.size() * 1e-2).max(1e-3)) else { return self.face_count() };
let tol = (self.size() * 1e-2).max(1e-3);
if let Some((cached_tol, missing)) = self.coarse_missing
&& cached_tol == tol
{
return missing;
}
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
+14 -1
View File
@@ -270,7 +270,7 @@ fn build(
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
return None;
}
let sb = r / (phi / 2.0).tan();
let sb = if round { r / (phi / 2.0).tan() } else { r };
Some((m, t, sb, r / (phi / 2.0).sin()))
};
// The straight seam between the walls at a sharp joint (direction away from the face).
@@ -328,6 +328,19 @@ fn build(
}
pb.push(p + e * (sb / h1));
}
// Sharp joints between straight rim edges can extend beyond the old corner.
// Equal wall slopes reach this path instead of the corner builder, and must
// also reject an extension through the outer wall or another hole.
for (i, q) in pa.iter().enumerate() {
// Smooth arc transitions and open-run ends retain their existing checks.
if !mitre.get(i).copied().unwrap_or(false) {
continue;
}
let p = joint_point(i).ok_or_else(|| unsupported("degenerate loop joint"))?;
if !crate::blend::extension_fits(f, p, *q, size * 1e-7) {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
// The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks
// to a point (a rounded corner rounded again: the blend there is a sphere).
let mut collapsed = vec![false; k.max(nj)];
+16 -2
View File
@@ -101,7 +101,14 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
t.swap(1, 2);
}
}
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None })
Ok(Body {
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: None,
paint: None,
splits: None,
})
}
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
@@ -185,6 +192,13 @@ impl Body {
t.swap(1, 2);
}
}
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
Some(Body {
solid: self.solid.clone(),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: self.color,
paint: self.paint.clone(),
splits: self.splits.clone(),
})
}
}
+9
View File
@@ -248,6 +248,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
// bounds are checked).
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
// pieces along slab boundaries. Merge those coplanar faces just as the
// general B-rep boolean does, retaining intentional Split Face lines.
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
let r = if op == BoolOp::Union {
r.map(|body| guard("heal prism boolean", || Body::new(crate::heal::heal_keep((*body.solid).clone(), size, &keep))).unwrap_or(body))
} else {
r
};
match &r {
Some(body) => {
let v = volume(body);
+136 -2
View File
@@ -3,6 +3,8 @@
//! spanning the target's height, the result is the prism over the 2D boolean of their
//! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! intersection's trouble with walls meeting along lines.
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
//! circular boss can meet the rim without shifting either operand off its exact plane.
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
@@ -418,7 +420,6 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
/// line or an arc.
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
@@ -434,6 +435,12 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
};
slabs.push(oriented(&s));
}
sew_sections(plane, d, &levels, &slabs, tol)
}
/// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
match (x.is_empty(), y.is_empty()) {
@@ -507,7 +514,7 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
Some(crate::sew::sew(&faces))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
// Candidate directions: a's planar face normals (each line once).
@@ -573,3 +580,130 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
}
Some(Ok(Some(first)))
}
/// A stepped extrusion (case floor plus walls), recovered from its cap events.
/// Downward caps add a section above their level; upward caps remove one.
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
use mt::{ParametricSurface3D, SearchNearestParameter};
if let Some(p) = prism_of(b, d, plane, tol) {
return Some(vec![p]);
}
if b.face_count() > 2048 {
return None;
}
let mut events = Vec::new();
for f in b.solid.face_iter() {
let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf {
let n = p.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
if n.dot(d).abs() > 1.0 - 1e-9 {
let mut loops = Vec::new();
for w in f.boundaries() {
let mut segs = Vec::new();
for e in w.edge_iter() {
segs.extend(seg2(e, plane, tol)?);
}
loops.push(Loop2 { segs });
}
let (outer, holes) = split_outer(loops)?;
let region = Region2 { outer, holes };
events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, oriented(&[region])));
continue;
}
if n.dot(d).abs() > 1e-9 {
return None;
}
} else {
// Curved walls must be extruded along d, not slanted or rounded caps.
for v in f.vertex_iter() {
let (u, w) = surf.search_nearest_parameter(v.point(), None, 50)?;
let n = surf.normal(u, w);
if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 {
return None;
}
}
}
}
let mut levels: Vec<f64> = events.iter().map(|(z, _, _)| *z).collect();
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
if levels.len() < 2 || levels.len() > 64 || levels.iter().any(|z| !z.is_finite()) {
return None;
}
let mut current = Vec::new();
let mut stack = Vec::new();
for (i, z) in levels.iter().enumerate() {
// Remove terminating material before adding any material starting here.
for up in [true, false] {
for (_, _, section) in events.iter().filter(|(h, u, _)| (*h - *z).abs() < tol && *u == up) {
current = match (current.is_empty(), up) {
(true, false) => section.clone(),
(true, true) => return None,
(false, _) => boolean2d(&current, section, if up { BoolOp::Cut } else { BoolOp::Union }, tol)?,
};
}
}
if let Some(next) = levels.get(i + 1)
&& !current.is_empty()
{
stack.push(Prism { lo: *z, hi: *next, regions: oriented(&current) });
}
}
(current.is_empty() && !stack.is_empty()).then_some(stack)
}
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
// This path repairs joins with a common outer cap (a boss flush with a rim).
// Other extents and subtractive operations retain their existing B-rep path.
if op != BoolOp::Union {
return None;
}
fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] {
ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.regions.as_slice()).unwrap_or(&[])
}
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
let mut dirs: Vec<Vec3> = Vec::new();
for f in a.solid.face_iter() {
if let mt::Surface::Plane(p) = f.oriented_surface() {
let n = p.normal();
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized()
&& !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9)
{
dirs.push(n);
}
}
}
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
let plane = Plane::from_normal(Vec3::ZERO, d)?;
Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?))
})?;
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
let (ea, eb) = (ends(&pa), ends(&pb));
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
return None;
}
let mut levels: Vec<f64> = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect();
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
let mut slabs = Vec::new();
for w in levels.windows(2) {
let (lo, hi) = (*w.first()?, *w.get(1)?);
let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol));
let s = match (x.is_empty(), y.is_empty(), op) {
(true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(),
(true, _, BoolOp::Union) => y.to_vec(),
(_, true, _) => x.to_vec(),
_ => boolean2d(x, y, op, tol)?,
};
slabs.push(oriented(&s));
}
if slabs.iter().all(Vec::is_empty) {
return Some(Ok(None));
}
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten()
}
+4 -1
View File
@@ -877,7 +877,10 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
}
let shells = std::mem::take(&mut imp.shells);
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp))
Ok((
Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() },
imp,
))
})
}
+16 -2
View File
@@ -22,7 +22,14 @@ impl Body {
/// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body {
let shell = mt::Shell::from(faces);
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }
Body {
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
coarse_missing: None,
mesh: None,
color: None,
paint: None,
splits: None,
}
}
/// A body from faces: a solid when they close, else a surface.
@@ -31,7 +38,14 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
if shell.shell_condition() == mt::ShellCondition::Closed {
Body::new(mt::Solid::new_unchecked(vec![shell]))
} else {
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None })
Ok(Body {
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
coarse_missing: None,
mesh: None,
color: None,
paint: None,
splits: None,
})
}
}
+45
View File
@@ -302,6 +302,8 @@ fn planar_booleans_with_coincident_faces() {
// Through the public boolean (B-rep first, polygon fallback).
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9);
assert_eq!(u2.face_count(), 8, "coplanar wall and base faces must merge in the B-rep");
assert!(u2.validity().is_empty());
// Two boxes sharing a face, and a cut flush with a side.
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 10.0, 10.0)).unwrap();
@@ -315,6 +317,32 @@ fn planar_booleans_with_coincident_faces() {
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
}
#[test]
fn corner_boss_on_stepped_case_has_one_exact_top_face() {
let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap();
let cavity = box_solid(Vec3::new(-48.0, -23.0, 2.0), Vec3::new(48.0, 23.0, 26.0)).unwrap();
let case = boolean(&outer, &cavity, BoolOp::Cut).unwrap().unwrap();
let boss = cylinder(Vec3::new(-45.0, 20.0, 2.0), Vec3::Z, 5.0, 23.0).unwrap();
let joined = boolean(&case, &boss, BoolOp::Union).unwrap().unwrap();
let common = boolean(&case, &boss, BoolOp::Intersect).unwrap().unwrap();
let cut = boolean(&case, &boss, BoolOp::Cut).unwrap().unwrap();
let (a, b, u, i, c) =
(measure(&case).unwrap(), measure(&boss).unwrap(), measure(&joined).unwrap(), measure(&common).unwrap(), measure(&cut).unwrap());
assert!(rel(u.volume + i.volume, a.volume + b.volume) < 1e-4);
assert!(rel(c.volume + i.volume, a.volume) < 1e-4);
for body in [&joined, &common, &cut] {
assert!(body.validity().is_empty());
}
assert!((u.bbox.max.z - 25.0).abs() < 1e-9, "the boss must not be nudged above the rim");
let top_faces = |body: &Body| {
body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 25.0).abs() < 1e-9).count()
};
assert_eq!(top_faces(&joined), 1, "the boss and both walls share one top face");
let rounded = fillet(&joined, &[Vec3::new(-50.0, 25.0, 12.5)], 5.0).unwrap();
assert!(rounded.validity().is_empty());
assert_eq!(top_faces(&rounded), 1, "rounding the outside corner must retain the merged top");
}
#[test]
fn sphere_booleans() {
let s = sphere(Vec3::ZERO, 15.0).unwrap();
@@ -1549,3 +1577,20 @@ fn raise_band_on_a_rounded_rim() {
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
}
}
#[test]
fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
for scale in [0.001, 1.0, 1000.0] {
let body = box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.) * scale).unwrap();
let tol = (body.size() * 0.01).max(1e-3);
let mesh = body.tessellate(tol).unwrap();
let mut present = vec![false; body.face_count()];
for face in mesh.tri_face {
present[face as usize] = true;
}
assert_eq!(body.unmeshed_faces(), present.iter().filter(|p| !**p).count());
if scale == 1.0 {
assert_eq!(body.coarse_missing, Some((tol, 0)));
}
}
}
+11
View File
@@ -127,13 +127,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"build_ms": app.viewport.build_ms,
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
"recent_thumbnails_ready": app.home.ready_recents(),
"documents": app.docs.count(),
"quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(),
})),
"ui.file" => {
let Some(path) = s("path") else { return err("path is required") };
if p.get("insert").and_then(Value::as_bool).unwrap_or(false) {
app.insert_path_async(path);
} else {
app.open_path_async(path);
}
ok(json!({"busy": app.file_load.busy()}))
}
"ui.documents" => ok(crate::documents::control(app, p)),
"ui.home" => {
match p.get("open").and_then(Value::as_bool) {
+1 -1
View File
@@ -33,7 +33,7 @@ pub enum Kind {
distance: String,
direction: usize,
operation: usize,
/// The operation follows the geometry (into a body: cut, out of one: join, else new)
/// Into a body: cut; additions: join when a solid exists, otherwise new.
/// until the user picks one.
auto_op: bool,
/// The other side's distance (two sides).
+2 -2
View File
@@ -91,7 +91,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
};
put_view(app, slot.view);
// Views cache by revision: the incoming design must not look unchanged.
app.session.revision = app.session.revision.max(out_rev) + 1;
app.session.invalidate_view(out_rev);
app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() };
out
}
@@ -141,7 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
crate::dialogs::cancel(app);
app.tool = None;
app.session = session;
app.session.revision = app.session.revision.max(rev) + 1;
app.session.invalidate_view(rev);
app.cam = Camera::default();
app.fit_view();
} else if let Err(e) = open_tab(app, session) {
+261
View File
@@ -0,0 +1,261 @@
//! Interactive file operations run off the window thread. The old design stays intact until
//! the command and display meshes are ready; failures and cancelled jobs never replace it.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
mpsc::{Receiver, TryRecvError},
};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use solvecraft_engine::{Session, doc::Document};
use crate::SolveApp;
struct Request {
path: String,
insert: bool,
}
struct Made {
session: Session,
result: Value,
image: Option<egui::ColorImage>,
}
type Job = Result<Made, String>;
struct Running {
request: Request,
base: Arc<Document>,
before: Vec<String>,
rx: Receiver<Job>,
phase: Arc<AtomicU8>,
cancelled: Arc<AtomicBool>,
}
#[derive(Default)]
pub struct FileLoad {
queued: VecDeque<Request>,
running: Option<Running>,
}
impl FileLoad {
pub fn busy(&self) -> bool {
self.running.as_ref().is_some_and(|r| !r.cancelled.load(Ordering::Relaxed)) || !self.queued.is_empty()
}
}
pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
#[cfg(target_arch = "wasm32")]
{
if insert {
app.insert_path(path);
} else {
app.open_path(path);
}
}
#[cfg(not(target_arch = "wasm32"))]
{
if app.file_load.queued.len() >= crate::documents::MAX_OPEN {
app.set_status("too many queued files", true);
return;
}
app.file_load.queued.push_back(Request { path: path.into(), insert });
start(app);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn start(app: &mut SolveApp) {
if app.file_load.running.is_some() {
return;
}
let Some(request) = app.file_load.queued.pop_front() else { return };
if !request.insert && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) {
app.set_status("too many open designs; close a tab before opening another", true);
return;
}
let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let mut scratch = if request.insert { app.session.scratch() } else { Session::default() };
if request.insert {
scratch.selection = app.session.selection.clone();
}
let base = app.session.doc.clone();
let phase = Arc::new(AtomicU8::new(0));
let worker_phase = phase.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let worker_cancelled = cancelled.clone();
let (tx, rx) = std::sync::mpsc::channel();
let path = request.path.clone();
let insert = request.insert;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled)))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let _ = tx.send(result);
});
match spawned {
Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }),
Err(e) => app.set_status(format!("could not start file operation: {e}"), true),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let command = if !insert {
"doc.open"
} else if solvecraft_engine::io::is_mesh_path(path) {
"file.insert_mesh"
} else {
"file.insert_step"
};
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
phase.store(1, Ordering::Relaxed);
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &session.world_state().bodies {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let _ = b.mesh();
}
phase.store(2, Ordering::Relaxed);
let image = if insert { None } else { crate::home::remember(&session) };
Ok(Made { session, result, image })
}
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() {
Ok(job) => Some(job),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("the file operation stopped; the current design is unchanged".into())),
});
if let Some(job) = result
&& let Some(running) = app.file_load.running.take()
{
match job {
_ if running.cancelled.load(Ordering::Relaxed) => {}
Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => {
if running.request.insert {
app.session.accept_computed(made.session);
app.big_operation = true;
} else {
crate::documents::adopt(app, made.session);
app.home.add_recent(&running.request.path);
crate::home::install(&mut app.home, ctx, &running.request.path, made.image);
}
app.home.open = false;
app.fit_view();
crate::stand_up::offer(app, &running.request.path, &running.before);
let warnings = made.result.get("warnings").and_then(Value::as_array);
app.set_status(
if let Some(w) = warnings.filter(|w| !w.is_empty()) {
format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or(""))
} else {
format!("{} done", if running.request.insert { "Insert" } else { "Open" })
},
false,
);
}
Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true),
Err(e) => app.set_status(e, true),
}
}
#[cfg(not(target_arch = "wasm32"))]
start(app);
if app.file_load.busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(r) = &app.file_load.running else { return };
let cancelled = r.cancelled.load(Ordering::Relaxed);
if cancelled && app.file_load.queued.is_empty() {
return;
}
let name = std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy()).unwrap_or_default();
let phase = r.phase.load(Ordering::Relaxed);
let mut cancel = false;
egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| {
ui.heading(if cancelled {
"Waiting for file operation"
} else if r.request.insert {
"Inserting geometry"
} else {
"Opening design"
});
ui.label(name.as_ref());
ui.horizontal(|ui| {
ui.spinner();
ui.label(if cancelled {
"Waiting for the previous operation to stop..."
} else {
match phase {
1 => "Preparing the view...",
2 => "Preparing the thumbnail...",
_ => "Reading and building geometry...",
}
});
});
cancel = ui.button("Cancel").clicked();
});
if cancel {
// Keep the running job until it exits: cancelling must not allow another large
// import to overlap it in memory. The user can continue editing the current design.
if let Some(r) = &app.file_load.running {
r.cancelled.store(true, Ordering::Relaxed);
}
app.file_load.queued.clear();
app.set_status("file operation cancelled", false);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_import_keeps_the_current_document() {
let mut app = SolveApp::new(Session::default(), Default::default());
let old = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Err("bad STEP".into())).unwrap();
app.file_load.running = Some(Running {
request: Request { path: "bad.step".into(), insert: true },
base: old.clone(),
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)),
});
tick(&mut app, &egui::Context::default());
assert!(Arc::ptr_eq(&app.session.doc, &old));
assert!(!app.file_load.busy());
assert!(app.status.as_ref().unwrap().0.contains("bad STEP"));
}
#[test]
fn cancelling_discards_a_finished_result_and_keeps_the_worker_slot_until_completion() {
let mut app = SolveApp::new(Session::default(), Default::default());
let old = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(true));
app.file_load.running = Some(Running {
request: Request { path: "cancelled.step".into(), insert: true },
base: old.clone(),
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled,
});
assert!(!app.file_load.busy());
assert!(app.file_load.running.is_some());
let mut computed = Session::default();
computed.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
tx.send(Ok(Made { session: computed, result: Value::Null, image: None })).unwrap();
tick(&mut app, &egui::Context::default());
assert!(app.file_load.running.is_none());
assert!(Arc::ptr_eq(&old, &app.session.doc));
}
}
+164 -5
View File
@@ -31,11 +31,17 @@ pub struct HomeState {
queued: Vec<String>,
#[cfg(not(target_arch = "wasm32"))]
rx: Option<std::sync::mpsc::Receiver<Made>>,
#[cfg(not(target_arch = "wasm32"))]
remembering: Option<std::sync::mpsc::Receiver<Made>>,
/// A sample picked before its design was ready.
wanted: Option<usize>,
}
impl HomeState {
pub fn ready_recents(&self) -> usize {
self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count()
}
pub fn show(&mut self, _rev: u64) {
self.open = true;
}
@@ -67,8 +73,104 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
}
/// File identity, not just its path: saving or replacing a design invalidates its picture.
#[cfg(not(target_arch = "wasm32"))]
fn cache_path(key: &str) -> Option<std::path::PathBuf> {
use std::hash::{Hash, Hasher};
let meta = std::fs::metadata(key).ok()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
meta.len().hash(&mut h);
meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.hash(&mut h);
let root = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("XDG_CACHE_HOME"))
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
Some(root.join("SolveCraft").join("thumbnails").join(format!("{:016x}.png", h.finish())))
}
#[cfg(not(target_arch = "wasm32"))]
fn cached(key: &str) -> Option<egui::ColorImage> {
let path = cache_path(key)?;
if std::fs::metadata(&path).ok()?.len() > 512 * 1024 {
return None;
}
let bytes = std::fs::read(path).ok()?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(THUMB[0] as u32);
limits.max_image_height = Some(THUMB[1] as u32);
limits.max_alloc = Some((THUMB[0] * THUMB[1] * 8) as u64);
reader.limits(limits);
let image = reader.decode().ok()?.to_rgba8();
(image.width() as usize == THUMB[0] && image.height() as usize == THUMB[1])
.then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw()))
}
/// Render from the model already built by Open/Save, rather than evaluating its file again.
pub(crate) fn remember(s: &Session) -> Option<egui::ColorImage> {
#[cfg(not(target_arch = "wasm32"))]
let identity = s.path.as_deref().and_then(cache_path);
remember_at(
s,
#[cfg(not(target_arch = "wasm32"))]
identity,
)
}
fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option<std::path::PathBuf>) -> Option<egui::ColorImage> {
let img = thumbnail(s)?;
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
if let Some(bytes) = solvecraft_engine::render::png(THUMB[0], THUMB[1], rgba)
&& let Some(dir) = path.parent()
&& std::fs::create_dir_all(dir).is_ok()
{
let _ = solvecraft_engine::io::write_atomic(&path, &bytes, false);
}
}
Some(img)
}
pub(crate) fn remember_session(h: &mut HomeState, s: &Session) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = &s.path {
let identity = cache_path(path);
let mut snapshot = s.scratch();
snapshot.path = Some(path.clone());
let key = path.clone();
let (tx, rx) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("saved-thumbnail".into())
.spawn(move || {
let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten();
let _ = tx.send((key, img, None));
})
.is_ok()
{
h.remembering = Some(rx);
h.queued.retain(|k| k != path);
h.thumbs.insert(path.clone(), None);
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (h, s);
}
}
pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option<egui::ColorImage>) {
let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR));
h.thumbs.insert(key.to_string(), tex);
}
/// Build what a key stands for (runs off the UI thread where threads exist).
fn make(key: &str) -> Made {
#[cfg(not(target_arch = "wasm32"))]
if let Some(img) = cached(key) {
return (key.to_string(), Some(img), None);
}
let mut s = Session::default();
let built = match key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok()) {
Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| {
@@ -83,7 +185,7 @@ fn make(key: &str) -> Made {
if !built {
return (key.to_string(), None, None);
}
let img = thumbnail(&s);
let img = remember(&s);
(key.to_string(), img, key.starts_with("sample:").then_some(s))
}
@@ -92,7 +194,18 @@ fn start_jobs(h: &mut HomeState) {
if h.rx.is_some() || h.queued.is_empty() {
return;
}
let keys = std::mem::take(&mut h.queued);
let mut keys = std::mem::take(&mut h.queued);
// Cached recents first, then cold recents, then samples. A sample must never hold up
// pictures of the user's work; a cold file must not hold up already cached pictures.
keys.sort_by_key(|k| {
if k.starts_with("sample:") {
2
} else if cache_path(k).is_some_and(|p| p.exists()) {
0
} else {
1
}
});
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || {
for k in keys {
@@ -112,12 +225,31 @@ fn start_jobs(h: &mut HomeState) {
fn poll(app: &mut SolveApp, ctx: &egui::Context) {
let mut done: Vec<Made> = Vec::new();
#[cfg(not(target_arch = "wasm32"))]
let mut remembered = None;
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(rx) = &app.home.remembering {
match rx.try_recv() {
Ok(m) => {
remembered = Some(m);
app.home.remembering = None;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.remembering = None,
Err(std::sync::mpsc::TryRecvError::Empty) => {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
}
start_jobs(&mut app.home);
if let Some(rx) = &app.home.rx {
loop {
match rx.try_recv() {
Ok(m) => done.push(m),
Ok(m) => {
// A cold job started before a save/open cannot overwrite its fresh picture.
if m.0.starts_with("sample:") || !app.home.thumbs.get(&m.0).is_some_and(Option::is_some) {
done.push(m);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.home.rx = None;
@@ -126,6 +258,9 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) {
}
}
}
if let Some(m) = remembered {
done.push(m);
}
if app.home.rx.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
@@ -185,7 +320,7 @@ pub fn open_recent(app: &mut SolveApp, path: &str) {
return;
}
app.home.open = false;
app.open_path(path);
app.open_path_async(path);
}
/// The page itself (in place of the toolbar, browser and viewport).
@@ -250,7 +385,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
action = Some(Box::new(|app: &mut SolveApp| {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.home.open = false;
app.open_path(&p);
app.open_path_async(&p);
}
}));
}
@@ -416,6 +551,30 @@ fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("solvecraft-thumb-{}-{nonce}.solvecraft", std::process::id()));
// Deliberately invalid design: a cached recent must not parse/evaluate it.
let bytes = b"{\"thumbnail_test\":true}";
std::fs::write(&path, bytes).unwrap();
let key = path.to_string_lossy().to_string();
let mut s = Session::default();
s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
s.path = Some(key.clone());
let image = remember(&s).unwrap();
let png = cache_path(&key).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let (_, cached_image, session) = make(&key);
assert!(session.is_none());
assert_eq!(cached_image.unwrap().pixels, image.pixels);
std::fs::write(&path, b"changed file identity").unwrap();
assert!(cached(&key).is_none());
std::fs::remove_file(path).unwrap();
std::fs::remove_file(png).unwrap();
}
#[test]
fn recent_list_is_newest_first_and_bounded() {
let mut h = HomeState::default();
+21 -3
View File
@@ -54,6 +54,7 @@ mod status_tests {
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
}
}
mod file_load;
pub mod palette;
pub mod params_dialog;
pub mod prefs;
@@ -230,6 +231,7 @@ pub struct SolveApp {
fitted: bool,
/// The start page.
pub home: home::HomeState,
pub file_load: file_load::FileLoad,
/// The other open designs (tabs).
pub docs: documents::Documents,
}
@@ -281,6 +283,7 @@ impl SolveApp {
styled: false,
fitted: false,
home: home::HomeState::default(),
file_load: file_load::FileLoad::default(),
docs: documents::Documents::default(),
}
}
@@ -388,6 +391,9 @@ impl SolveApp {
/// Run a command programmatically (JSON parameters, no dialogs).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if self.file_load.busy() {
return Err("a file operation is in progress".into());
}
let t0 = now_ms();
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
@@ -406,6 +412,9 @@ impl SolveApp {
&& let Some(p) = self.session.path.clone()
{
self.home.add_recent(&p);
if matches!(id, "file.save" | "file.save_as") {
home::remember_session(&mut self.home, &self.session);
}
}
}
Err(e) => {
@@ -441,7 +450,7 @@ impl SolveApp {
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
self.insert_path_async(&p);
}
return;
}
@@ -622,6 +631,13 @@ impl SolveApp {
}
}
pub fn open_path_async(&mut self, path: &str) {
file_load::begin(self, path, false);
}
pub fn insert_path_async(&mut self, path: &str) {
file_load::begin(self, path, true);
}
/// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
/// current design.
pub fn insert_path(&mut self, path: &str) {
@@ -641,9 +657,9 @@ impl SolveApp {
pub fn drop_path(&mut self, path: &str) {
let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path);
if import && !self.session.doc.features.is_empty() {
self.insert_path(path);
self.insert_path_async(path);
} else {
self.open_path(path);
self.open_path_async(path);
}
}
@@ -733,6 +749,7 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
if !self.styled || theme::is_dark() != self.ui.dark {
@@ -798,6 +815,7 @@ impl SolveApp {
let t0 = now_ms();
scenario::clear_handles();
toolbar::app_bar(self, ui);
file_load::show(self, ui.ctx());
documents::prompt(self, ui.ctx());
if self.home.open {
home::show(self, ui);
+1 -1
View File
@@ -865,7 +865,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
}
}
}
"quit_requested" | "quitting" => {
"file_busy" | "quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
+5 -2
View File
@@ -161,7 +161,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
if item(ui, "Open…", "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
app.open_path_async(&p);
}
close = true;
}
@@ -531,6 +531,9 @@ fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
/// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if app.file_load.busy() {
return;
}
// The Keyboard Shortcuts window is waiting for a key: it is not a shortcut.
if crate::keymap::capturing(app) {
return;
@@ -554,7 +557,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
app.open_path_async(&p);
}
}
_ => {
+263 -30
View File
@@ -43,7 +43,7 @@ pub struct ViewportState {
pub mouse: Option<Pos2>,
/// Box selection being dragged.
pub boxsel: Option<BoxSel>,
/// Timeline item under the cursor: its bodies are highlighted.
/// Timeline item under the cursor: its bodies or resulting faces are highlighted.
pub hover_feature: Option<u64>,
/// Bodies whose browser row is under the cursor: highlighted the same way.
pub hover_bodies: Vec<String>,
@@ -51,6 +51,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// A primary press on the cube: the pointer position used by its last orbit step.
pub cube_drag: Option<Pos2>,
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
@@ -860,13 +862,55 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
app.session.section
}
/// Visible geometry belonging to a timeline feature. Body creators retain their whole-body
/// feedback; modifiers use persistent face names, which survive later edits and placement.
/// Do not expand these faces into surface patches: neighbouring faces can belong to another
/// feature even when they share a surface.
fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) {
return Vec::new();
}
let st = app.session.world_state();
let prefix = format!("F{id}:");
let mut out = Vec::new();
for b in &st.bodies {
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
continue;
}
if b.feature == id {
out.push(Sel::Body { name: b.name.clone() });
continue;
}
let names = solvecraft_engine::doc::naming::face_names(b);
let m = b.mesh();
for (index, _) in names.iter().enumerate().filter(|(_, n)| n.starts_with(&prefix)) {
if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != index {
return None;
}
m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0)
}) {
out.push(Sel::Face { body: b.name.clone(), index, point });
}
}
}
out
}
fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec<Sel> {
match item {
Sel::Feature { id } => feature_geometry(app, *id),
_ => grouped_selection(&app.session, item),
}
}
/// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the
/// bodies of selected features), where it is shown.
pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3> {
let st = app.session.world_state();
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
for s in &app.session.selection {
match s {
for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) {
match &s {
Sel::Body { name } => {
if let Some(x) = st.body(name) {
b = b.union(&x.mesh().bounds());
@@ -892,11 +936,6 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3
}
}
Sel::Vertex { point, .. } => b.add(*point),
Sel::Feature { id } => {
for x in st.bodies.iter().filter(|x| x.feature == *id) {
b = b.union(&x.mesh().bounds());
}
}
Sel::SketchCurve { id } => {
for ss in &st.sketches {
if let Some(i) = ss.sketch.curve_index(id) {
@@ -1093,7 +1132,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let st = app.session.world_state();
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in grouped_selection(&app.session, &item) {
for piece in displayed_selection(app, &item) {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
@@ -1211,21 +1250,40 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
_ => {}
}
}
// Bodies made by the timeline item under the cursor, or whose browser row is.
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() {
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) {
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
let faces: Vec<_> = if whole {
(0..b.body.face_count()).collect()
} else {
hovered
.iter()
.filter_map(|x| match x {
Sel::Face { body, index, .. } if body == &b.name => Some(*index),
_ => None,
})
.collect()
};
if faces.is_empty() {
continue;
}
let k = t.hover_face_lift;
let lit =
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) {
let c = body_rgb(app, &b.name);
let m = b.mesh();
for f in 0..b.body.face_count() {
let c = body_rgb(app, &b.name);
let m = b.mesh();
for &f in &faces {
if !sel.iter().any(|x| {
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
}) {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false) {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false)
&& (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize)))))
{
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
}
@@ -1401,7 +1459,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let (dx, dy) = (delta.x as f64, delta.y as f64);
let mode = if middle || secondary_down {
crate::prefs::nav_mode(app, middle, secondary_down, mods)
} else if primary_down {
} else if primary_down && app.viewport.cube_drag.is_none() {
app.viewport.nav
} else {
None
@@ -1448,8 +1506,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// 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 navigating = scroll != 0.0
|| middle
|| secondary_down
|| (app.viewport.nav.is_some() && primary_down)
|| app.viewport.cube_drag.is_some()
|| 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());
@@ -2084,16 +2147,45 @@ fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
}
/// Dragging a primary press on the cube orbits about the view centre. A stationary press
/// remains a click; a floating window over the cube owns its input.
fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool {
let (pressed, down, decided, origin, pos) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos())
});
if pressed && ui.rect_contains_pointer(rect) {
let shapes = cube_projected(targets, cube_basis(&app.cam), c);
app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some());
}
if !down {
app.viewport.cube_drag = None;
}
let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false };
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
// Include the movement that crossed the drag threshold, so the orbit follows the pointer.
if let Some(p) = pos
&& p != last
{
app.cancel_view_animation();
app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y));
app.viewport.cube_drag = Some(p);
ui.ctx().request_repaint();
}
true
}
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
let targets = cube_targets();
let orbiting = view_cube_orbit(app, ui, rect, &targets, c);
let (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let targets = cube_targets();
let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
let hovered = if orbiting { None } else { hover.and_then(|h| cube_hit(&shapes, h)) };
// Axis triad from the cube's back corner, drawn first: the cube hides what is behind it.
// The corner is in the cube's frame, the axes are the world's.
let o = Vec3::new(-1.0, -1.0, -1.0);
@@ -2137,14 +2229,34 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
if let Some(dir) = clicked {
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
// TOP and BOTTOM turn to the standard plan views (front edge at the bottom).
// Keep the nearest square heading when looking straight down or up.
if dir.x == 0.0 && dir.y == 0.0 {
to.yaw = 0.0;
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
app.animate_to(to);
}
}
/// The nearest square heading. At a halfway tie, prefer the heading nearer the standard
/// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom.
fn nearest_quarter_turn(yaw: f64) -> f64 {
if !yaw.is_finite() {
return 0.0;
}
let q = std::f64::consts::FRAC_PI_2;
let lo = (yaw / q).floor() * q;
let hi = lo + q;
let (dlo, dhi) = (yaw - lo, hi - yaw);
if (dlo - dhi).abs() < 1e-6 {
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
if off_front(lo) <= off_front(hi) { lo } else { hi }
} else if dlo < dhi {
lo
} else {
hi
}
}
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
const CUBE_CHAMFER: f64 = 0.38;
@@ -2206,7 +2318,7 @@ fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
}
/// A face's text directions (right, up): read from outside the cube, and upright in the view
/// clicking the face turns to (TOP and BOTTOM: the plan views, front edge at the bottom).
/// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom).
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) }
}
@@ -2424,6 +2536,80 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
#[test]
fn timeline_modifiers_highlight_on_hover_and_click() {
for command in ["solid.fillet", "solid.chamfer"] {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = false;
let base = h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap()["feature"].as_u64().unwrap();
let id = h.app.run(command, json!({"edges":[[0,0,10]],"radius":2,"distance":2,"name":"Blend"})).unwrap()["feature"].as_u64().unwrap();
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0]})).unwrap();
h.frames(2);
let geometry = feature_geometry(&h.app, id);
assert_eq!(geometry.len(), 1, "{command}: {geometry:?}");
let Sel::Face { body, index, .. } = &geometry[0] else { panic!("a modifier must highlight its face") };
let st = h.app.session.world_state();
let m = st.body(body).unwrap().mesh();
let triangles = m.tri_face.iter().filter(|f| **f as usize == *index).count();
assert!(triangles > 0 && triangles < m.triangles.len());
let p = crate::scenario::handle_at("timeline:Blend").unwrap();
h.step(&json!({"move":[p.x,p.y]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, Some(id));
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
assert!(!build_highlight(&h.app).lines.is_empty());
h.step(&json!({"click":[p.x,p.y]})).unwrap();
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
// Moving away removes hover but leaves the selected blend highlighted.
h.step(&json!({"move":[5,5]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, None);
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
let bounds = selection_bounds(&h.app).unwrap();
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}");
// Creator features retain whole-body feedback, while hidden bodies stay hidden.
assert_eq!(feature_geometry(&h.app, base), vec![Sel::Body { name: body.clone() }]);
h.app.session.visibility.hidden_bodies.push(body.clone());
assert!(feature_geometry(&h.app, id).is_empty());
assert!(selection_bounds(&h.app).is_none());
assert!(build_highlight(&h.app).tris.is_empty());
}
}
#[test]
fn timeline_modifier_highlight_follows_edits_and_rollback() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
// A later chamfer changes the same body without consuming the fillet.
app.run("solid.chamfer", json!({"edges":[[20,20,10]],"distance":1})).unwrap();
app.run("solid.move", json!({"bodies":["Body1"],"translate":[30,0,0]})).unwrap();
app.run("body.rename", json!({"body":"Body1","name":"Moved"})).unwrap();
app.session.selection = vec![Sel::Feature { id }];
let bounds = selection_bounds(&app).unwrap();
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01, "{bounds:?}");
assert!(feature_geometry(&app, id).iter().all(|x| matches!(x, Sel::Face { body, .. } if body == "Moved")));
app.run("timeline.roll_to", json!({"position":1})).unwrap();
assert!(feature_geometry(&app, id).is_empty());
app.run("timeline.roll_to", json!({})).unwrap();
assert!(!feature_geometry(&app, id).is_empty());
app.run("timeline.suppress", json!({"feature":id})).unwrap();
assert!(feature_geometry(&app, id).is_empty());
}
#[test]
fn timeline_modifier_highlight_follows_component_instances() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.run("component.create", json!({"name":"Part"})).unwrap();
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap();
app.run("occurrence.copy", json!({"component":"Part","translate":[0,40,0]})).unwrap();
let geometry = feature_geometry(&app, id);
assert_eq!(geometry.len(), 2, "both occurrences retain the fillet's face: {geometry:?}");
app.session.selection = vec![Sel::Feature { id }];
let bounds = selection_bounds(&app).unwrap();
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01 && bounds.max.y >= 41.99, "{bounds:?}");
}
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted();
let _ = ctx.run_ui(
@@ -2748,6 +2934,9 @@ mod tests {
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the cube");
assert!(h.app.viewport.cube_drag.is_none());
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
@@ -2755,6 +2944,24 @@ mod tests {
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
}
#[test]
fn view_cube_drag_owns_navigation_and_keeps_the_view_centre() {
for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view": "front"}));
h.app.viewport.nav = nav;
let before = h.app.cam;
let p = crate::scenario::handle_at("cube:front").unwrap();
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8}));
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: cube drag must orbit exactly once");
assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan");
assert_eq!(h.app.cam.distance, before.distance);
assert_eq!(h.app.cam.pitch, before.pitch);
assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture");
assert!(h.app.viewport.boxsel.is_none());
}
}
#[test]
fn section_outlines_follow_the_cut() {
let mut s = solvecraft_engine::Session::default();
@@ -2832,7 +3039,7 @@ mod tests {
#[test]
fn view_cube_labels_read_upright_in_their_own_view() {
for tg in cube_targets().iter().filter(|t| !t.corner) {
// The view clicking the face turns to (TOP and BOTTOM with yaw 0).
// The standard face view (TOP and BOTTOM with yaw 0).
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
if tg.dir.z.abs() > 0.5 {
cam.yaw = 0.0;
@@ -2847,6 +3054,32 @@ mod tests {
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
}
#[test]
fn view_cube_top_bottom_choose_nearest_square_heading() {
for (from, to) in [
(0.0, 0.0),
(30.0, 0.0),
(-80.0, -90.0),
(100.0, 90.0),
(170.0, 180.0),
(400.0, 360.0),
(-400.0, -360.0),
(-45.0, 0.0),
(45.0, 0.0),
(135.0, 90.0),
(-135.0, -90.0),
(44.99, 0.0),
(45.01, 90.0),
(-44.99, 0.0),
(-45.01, -90.0),
] {
let got = nearest_quarter_turn(f64::to_radians(from)).to_degrees();
assert!((got - to).abs() < 1e-9, "{from}° → {got}°, expected {to}°");
}
assert_eq!(nearest_quarter_turn(f64::NAN), 0.0);
assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0);
}
/// The widest label spans 90% of its face, looking straight at it.
#[test]
fn view_cube_widest_label_spans_most_of_its_face() {
+2
View File
@@ -80,6 +80,7 @@ fn home_tab_and_file_recent_open_designs() {
{"expect":{"home":false,"bodies":1}},
{"click":{"handle":"tab:home"}},
{"click":{"handle":"home:recent:0"}},
{"until":{"file_busy":false}},
{"expect":{"home":false,"bodies":1,"documents":2}},
{"click":{"handle":"appbar:folder"}},
{"widget":"Open Recent"}
@@ -87,6 +88,7 @@ fn home_tab_and_file_recent_open_designs() {
.unwrap();
h.run(&json!([
{"widget":path},
{"until":{"file_busy":false}},
{"expect":{"home":false,"bodies":1,"documents":3}}
]))
.unwrap();
@@ -0,0 +1,12 @@
[
{"note": "An elbow addition meets the existing arm along a profile side, not behind its centre. The automatic dialog must keep Join selected."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 30}}},
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}}},
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [10, 20], "p1": [30, 30]}}},
{"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
{"start": "solid.extrude"},
{"text": "-10"},
{"key": "Enter"},
{"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": 5000}},
{"shot": "elbow_joined"}
]
@@ -83,6 +83,7 @@
{
"widget": "Insert STEP\u2026"
},
{"until": {"file_busy": false}},
{
"debug": 1
},
@@ -18,6 +18,7 @@
{"expect": {"bodies": 0}},
{"choose_file": "Sample Plate.solvecraft"},
{"key": "O", "cmd": true},
{"until": {"file_busy": false}},
{"debug": 1},
{"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}},
{"shot": "s15_reopened"}
@@ -208,7 +208,7 @@
"start": "solid.extrude"
},
{
"widget": "New Body"
"widget": "Join"
},
{
"widget": "Cut"
@@ -0,0 +1,10 @@
[
{"note": "A cube drag rotates by the whole pointer movement and releasing it does not click the pressed face."},
{"call": "ui.view", "params": {"view": "front"}},
{"drag": [{"cube": [0, -1, 0]}, {"cube": [1.5, -1, 0]}]},
{"expect": {"camera": {"yaw_deg": 22, "pitch_deg": 0}}},
{"note": "A stationary click still selects a face."},
{"call": "ui.view", "params": {"view": "home"}},
{"click": {"handle": "cube:front"}},
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}
]
@@ -0,0 +1,31 @@
[
{"note": "TOP and BOTTOM keep the nearest square heading, with home ties toward yaw zero. Clicking side faces still selects their standard view."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
{"call": "ui.view", "params": {"view": "home"}},
{"click": {"handle": "cube:top"}},
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 90}}},
{"call": "ui.view", "params": {"view": "home"}},
{"click": {"handle": "cube:top-back-right"}},
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
{"click": {"handle": "cube:top"}},
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 0, 1]}}},
{"shot": "minimal-turn-top-z"},
{"camera": {"yaw": 2.9670597283903604, "pitch": -0.6}},
{"click": {"handle": "cube:bottom"}},
{"expect": {"camera": {"yaw_deg": 180, "pitch_deg": -90, "back": [0, 0, -1]}}},
{"camera": {"yaw": 1.3962634015954636, "pitch": 0.6}},
{"click": {"handle": "cube:top"}},
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": 90}}},
{"call": "ui.view", "params": {"view": "home"}},
{"click": {"handle": "cube:front"}},
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}},
{"note": "The same heading choice is measured in the Y-up camera frame."},
{"call": "ui.prefs", "params": {"up_axis": "y"}},
{"camera": {"yaw": -1.3962634015954636, "pitch": 0.6}},
{"click": {"handle": "cube:top"}},
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 1, 0]}}},
{"shot": "minimal-turn-top-y"},
{"camera": {"yaw": 1.3962634015954636, "pitch": -0.6}},
{"click": {"handle": "cube:bottom"}},
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": -90, "back": [0, -1, 0]}}}
]
+6
View File
@@ -12,6 +12,12 @@ it.
| Construct | offset plane, plane at angle |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
Extrude automatically selects **Join** for additions when the active component already has a
solid, including sideways elbow connections and screw bosses that meet only part of a face.
Extruding into existing material selects **Cut**. In an empty component it selects **New Body**;
choose **New Body** explicitly to keep an addition separate. Changing the default does not alter
operations already saved in a design.
Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling
ball). See the roadmap's known limitations for the rest.
+4 -2
View File
@@ -15,8 +15,10 @@
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in
Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar
change the view: a cube face turns to its standard view, a chamfered corner to that corner's
iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a
change the view: a cube face turns to look straight at it (TOP and BOTTOM keep the nearest
quarter-turn heading), a chamfered corner to that corner's
iso view, and the house goes home. Drag the cube to orbit around the view centre.
Right-click for the marking menu: eight commands around the cursor, plus a
menu for whatever is under it.
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
value box in the viewport.
+1
View File
@@ -46,6 +46,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
+40
View File
@@ -113,3 +113,43 @@ errors) through edits, suppression, roll-back, undo and redo. Setting a sketch d
can still recompute (oracle 61, 62): solving the sketch moves its points by rounding noise, so
its inputs are not exactly the old ones. Oracle 59's first parameter edit fails (a recipe
expression the bench's `* 1.02` can't scale) and is left out.
## Desktop loading and recent thumbnails (2026-10-10)
The Arduino Uno enclosure (19 features, 34 KB) and Raspberry Pi 5 case (24 features,
61 KB) each decode in about 1 ms. A release build spends roughly 12.1 s and 8.2 s,
respectively, evaluating their geometry on this Windows machine. Changing the document
encoding does not eliminate these boolean operations.
Interactive Open, recent-file clicks, drag/drop and STEP/mesh insertion now execute on a
worker, including preparation of the world-space bodies and display meshes. The existing
design stays available until the result is ready; a failed or cancelled job cannot replace
it. Insertion preserves the file, saved state, earlier undo history and view settings.
Timeline copies share their lazy mesh cell, and adopting a prepared session keeps its warm
world-space cache instead of transforming and tessellating its components again on the UI.
A STEP body's missing-face check reuses its orientation tessellation when the tolerances
match and its orientation was not reversed.
Live desktop tests (`ci` profile, with other builds/tests running) opened the Arduino and
Pi case in 15.2 s and 10.5 s. During those jobs, median `ui.inspect` round trips were 20.5 ms
and 8.3 ms; the slowest were 33.7 ms and 28.6 ms. This fixes the window freeze, not the
remaining cost of cold geometry evaluation.
Recent thumbnails are 240 × 150 PNG files in `SolveCraft/thumbnails` under the platform
cache directory (`LOCALAPPDATA` on Windows). Their keys include the source path, file size
and modification time. Open and Save render from the model already in memory. Cached
recents decode the PNG without reading or rebuilding a document, and run before cold
recents and sample thumbnails. Missing or invalid pictures fall back to a background rebuild.
No thumbnail or base64 image is added to the design JSON. A regression test verifies the
source bytes remain unchanged, a cached picture works even without a parseable source
file, and changing the source invalidates the cache.
`solvecraft-cli bench-open` also accepts STEP/IGES. For exchange files its `parse` phase
includes constructing and checking the imported B-rep, not merely decoding STEP syntax.
The live test loaded both cached recent cards in 21 ms and inserted the 2 MB lightweight
Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodies. The
full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after
300 s. Its window continued handling inspection and Cancel, and cancelling preserved the
case. Complex STEP construction remains an outstanding kernel performance problem.
+5 -1
View File
@@ -117,7 +117,11 @@ Keep that deployment separate from building the application. A publication made
Actions `GITHUB_TOKEN` does not trigger downstream `release: published` workflows; in that
case explicitly dispatch the Flatpak workflow with the published tag, or use an appropriately
authorized maintainer/App token for publication. Do not assume the repository updated: check
the deployment run. Coordinate its GitHub Pages output with the existing docs website.
the deployment run. The Flatpak workflow requests the shared Pages workflow with that tag;
Pages assembles the documentation, browser app, and Flatpak repository together. Ordinary
docs updates include the latest stable Flatpak release too. Verify the resulting Pages run,
not only the dispatch job, so a release cannot erase the docs or app and docs updates cannot
erase the package repository.
## Repository setup
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# All Pages deployments include the book, web app, and published Flatpak repository.
# Shared by docs updates and release publication; no other workflow replaces the site.
set -euo pipefail
site="${1:?site directory required}"
tag="${RELEASE_TAG:-}"
app_id=ai.storyteller.solvecraft
if [ -z "$tag" ]; then
tag="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq '[.[] | select(.draft == false and .prerelease == false)][0].tag_name // empty')"
fi
# Before the first publication the docs and browser app can still deploy.
if [ -z "$tag" ]; then exit 0; fi
if ! printf '%s' "$tag" | grep -Eq '^v(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-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "invalid release tag: $tag" >&2
exit 1
fi
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
test "$published" = "$tag"
test -s "$site/index.html"
test -s "$site/app/index.html"
version="${tag#v}"
bundles="$(mktemp -d)"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern SHA256SUMS.txt --dir "$bundles"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
base_url="${PAGES_BASE_URL%/}"
previous_url="$base_url/flatpak/repo"
if curl --fail --silent --location --output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern "$asset" --dir "$bundles"
(
cd "$bundles"
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$bundles/$asset"
ostree --repo="$validation_repo" refs | grep -Fxq "app/$app_id/$arch/stable"
flatpak build-import-bundle "$repo_dir" "$bundles/$asset"
done
flatpak build-update-repo --title="SolveCraft" --comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" --default-branch=stable "$repo_dir"
test -s "$repo_dir/summary"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$base_url/flatpak/repo/
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$tag</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $app_id</pre></main></body></html>
HTML
touch "$site/.nojekyll"