Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fed469dc | ||
|
|
efa6706226 | ||
|
|
c1b6478ab5 | ||
|
|
17e8e3eff2 | ||
|
|
4b7e1d317d | ||
|
|
b375c2a92a | ||
|
|
5275911b44 | ||
|
|
95ae096265 | ||
|
|
5835bb10d4 | ||
|
|
db4f885bac |
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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, ®ion).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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t, 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(¤t) });
|
||||
}
|
||||
}
|
||||
(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()
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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, ¶ms).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);
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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]}}}
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user