Compare commits

...

11 Commits

Author SHA1 Message Date
bherbruck 0f8aa72732 Release: Windows jobs start at once on 8 vCPU runners
The icons job only copied the committed .ico after a cold build of the CLI, and the Windows
jobs waited for it; package.ps1 falls back to the same file. The Windows builds were 35 minutes
on a 2 vCPU runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:43:43 -04:00
bherbruck 2c5f8c1776 Build the macOS release binaries per architecture in parallel
The universal macOS job built aarch64 and x86_64 one after the other on a
3-core runner and took 45-50 minutes, the longest job of a release. Each
architecture now builds on its own runner and a small job joins them with lipo
and packages the dmg and CLI zip exactly as before.
2026-10-20 17:43:07 -04:00
bherbruck 522ae77556 Release 0.18.1 (#3851)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:33:29 -04:00
raykholo 5ba8c815a6 IGES import: trim curves with reversed pieces no longer drop their faces (#3358) (#3359)
* claim: #3358

* IGES import: trim curves with reversed pieces no longer drop their faces

A curve on a surface (142) gives each trim loop twice: B in the surface's
parameter space and C in model space, running the same way. Some exporters
write C as a composite curve (102) whose pieces are in order but some of
them end to start. The reader passed those pieces on as they were, so the
loop did not close and the STEP reader left the face out ("face boundary is
not a closed loop").

The pieces of C are now turned to run head to tail, starting at S(B(start)),
the point where the parameter-space curve begins on the surface. That keeps
the loop's sense from the file, so holes still run against the outer loop.
A piece whose ends are one vertex at the sewing tolerance keeps its
direction.

On a 9,276-face trimmed-surface car model, 7,887 faces now import instead
of 3,882.
2026-10-20 17:32:48 -04:00
bherbruck cb1b230732 Add Dutch, Swedish, Traditional Chinese, Indonesian, Romanian and Hungarian (#3826)
The new catalogs are machine translated; native speakers, corrections are very welcome.
2026-10-20 17:32:44 -04:00
bherbruck 6e22e3709d Web: reopen designs saved on the computer from the recent list after a reload; forget stale remembered files (#104) (#3834) 2026-10-20 17:32:36 -04:00
Krzysztof Heim 1b84ce6330 fix: skip impossible drags of determined sketch geometry (#2006) (#2199)
Co-authored-by: Krzysztof Heim <krzysztof.heim@b4bridge.com>
2026-10-20 20:56:50 +00:00
bherbruck 687caad24c IGES import: a revolved arc is a cap, not a whole sphere (#3806) (#3813)
A surface of revolution (120) wrote its arc generatrix as a whole STEP circle, which
turns into a whole sphere twice over when the arc is centred on the axis; the trim could
not pick the cap out of it and the face meshed as the full ball. An arc in a plane through
the axis now comes in as a sphere or torus, any other arc as an exact rational B-spline of
just the arc.
2026-10-20 20:56:35 +00:00
bherbruck 7e05c8b23c Hovering a long helical edge no longer freezes (#3745) (#3818)
Hovering an edge lights its circular rim, which starts from the edge's
tangent chain. That looked at every edge of the mesh for each edge in the
chain (and searched the chain itself each time), so a long helical edge in
many pieces, a thread's crest, took seconds: 7.6 s inside the frame on the
first hover of the stem's thread in a real watch-movement STEP file. The
edges are now found by the grid cells of their ends, two join tolerances
across, so each step looks only at the edges that end near it: the same chain
in the same order, in 29 ms for that thread.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:08:50 -04:00
DuBearte 7dfbaf2a2c Parameter autocomplete matches anywhere in the name, prefix matches first (#2757) (#2849)
* claim: #2757

* fix(params): autocomplete offers a parameter for any part of its name (#2757)

Typing `side` now offers `cab_side_thickness`: parameters match the word
anywhere in their name, after those that start with it, both in the expression
boxes and in parameters.complete. Functions, constants and units still match
from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 20:39:14 +00:00
phanoob 1e0e8f78dc Shift+R repeats the last tool or dialog (#3045) (#3160)
* claim: #3045

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shift+R repeats the last tool or dialog (#3045)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:29:22 +00:00
32 changed files with 12235 additions and 115 deletions
+53 -37
View File
@@ -8,7 +8,7 @@
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
@@ -83,28 +83,6 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
@@ -293,11 +271,19 @@ jobs:
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
strategy:
fail-fast: false
matrix:
include:
- { arch: aarch64, target: aarch64-apple-darwin }
- { arch: x86_64, target: x86_64-apple-darwin }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -305,12 +291,45 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
key: release-macos-${{ matrix.arch }}
- name: Build
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
# Not named release-*: the release job collects every release-* artifact as an asset.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: macos-bin-${{ matrix.arch }}
path: |
target/${{ matrix.target }}/release/solidcraft
target/${{ matrix.target }}/release/solidcraft-cli
target/${{ matrix.target }}/release/solvecraft-cli
if-no-files-found: error
macos:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-aarch64
path: target/aarch64-apple-darwin/release
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-x86_64
path: target/x86_64-apple-darwin/release
- name: Join, package and check (dmg, CLI zip; the dmg is mounted and checked)
run: |
# Artifacts don't keep the executable bit.
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
packaging/macos/package.sh --arch universal --skip-build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
@@ -319,9 +338,12 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
needs: version
if: needs.version.outputs.build == 'true'
runs-on: windows-latest
# An 8 vCPU runner: the release build was 35 minutes on a 2 vCPU windows-latest and is the
# longest job of the release. The installer icon is the committed
# assets/icons/app/windows/exports/solidcraft.ico (package.ps1's default), so nothing waits.
runs-on: blacksmith-8vcpu-windows-2025
strategy:
fail-fast: false
matrix:
@@ -334,10 +356,6 @@ jobs:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -351,8 +369,6 @@ jobs:
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+6
View File
@@ -106,17 +106,23 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
Generated
+17 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "solidcraft-cli"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"serde_json",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4217,7 +4217,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"serde",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"schemars",
@@ -4253,7 +4253,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"naga",
@@ -4279,7 +4279,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4302,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -4322,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"moxcms",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4356,14 +4356,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"arboard",
"egui",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"eframe",
"egui",
@@ -5920,7 +5920,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.18.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+143 -3
View File
@@ -59,6 +59,34 @@ pub fn make_id(ms: u64, random: u64) -> String {
format!("{ms:x}-{random:x}")
}
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
/// autosave, before the browser's copy of its handle is dropped.
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
/// What to do with the remembered handles.
#[derive(Debug, Default, PartialEq)]
pub struct PrunePlan {
/// Handles to forget.
pub delete: Vec<String>,
/// Handles seen unused for the first time: start their clock.
pub stamp: Vec<String>,
}
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
let mut plan = PrunePlan::default();
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
match stamped {
None => plan.stamp.push(id.clone()),
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
Some(_) => {}
}
}
plan
}
/// Where a design saved with Save As goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveTarget {
@@ -112,7 +140,7 @@ mod web {
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
use super::{is_abort, make_id, opens_design, parse, path, save_type};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
@@ -397,10 +425,106 @@ mod web {
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
}
/// Key of the time a handle was last used (the handle's own key is its id).
fn stamp_key(id: &str) -> String {
format!("at:{id}")
}
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readwrite)?.put_with_key(handle, &id.into()).map_err(err)?;
result(&req).await.map(|_| ())
let store = store(&db, IdbTransactionMode::Readwrite)?;
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
}
/// Note that the file was used now.
async fn touch(id: &str) {
let Ok(db) = open_db().await else { return };
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
{
let _ = result(&req).await;
}
}
/// The ids of every remembered file.
pub async fn known_ids() -> Result<Vec<String>, String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
}
async fn forget(ids: &[String]) -> Result<(), String> {
let db = open_db().await?;
let store = store(&db, IdbTransactionMode::Readwrite)?;
for id in ids {
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
HANDLES.with(|h| h.borrow_mut().remove(id));
}
Ok(())
}
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
/// and the autosave name), once they have gone unused for a long while.
pub fn prune(keep: std::collections::HashSet<String>) {
spawn_local(async move {
let r = async {
// A transaction ends when the page awaits anything but its own requests, so each
// step opens its own.
let known = known_ids().await?;
let db = open_db().await?;
let reading = store(&db, IdbTransactionMode::Readonly)?;
let mut ids = Vec::new();
for id in known {
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
ids.push((id, at));
}
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
for id in &plan.stamp {
touch(id).await;
}
forget(&plan.delete).await
};
if let Err(e) = r.await {
log::warn!("remembered files weren't tidied: {e}");
}
});
}
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
let r = async {
let handle = handle_for(&id).await?;
ensure_readwrite(&handle).await?;
read_handle(&handle).await
};
match r.await {
Ok(bytes) => {
vfs::insert(&path, bytes);
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
if gone {
let _ = forget(std::slice::from_ref(&id)).await;
files.disk_known.borrow_mut().remove(&id);
files.gone.borrow_mut().push(path);
files.say(format!("{name} isn't where it was saved any more"), true);
} else {
files.say(format!("{name}: {e}"), true);
}
}
}
});
}
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
@@ -455,6 +579,22 @@ mod tests {
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
}
#[test]
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
let day = 24.0 * 3600.0 * 1000.0;
let now = 1000.0 * day;
let ids = vec![
("recent".to_string(), Some(now - 200.0 * day)),
("old".to_string(), Some(now - 100.0 * day)),
("fresh".to_string(), Some(now - 5.0 * day)),
("unstamped".to_string(), None),
];
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
}
#[test]
fn handle_ids_differ_by_time_and_by_chance() {
assert_ne!(make_id(1, 5), make_id(2, 5));
+9 -1
View File
@@ -57,6 +57,10 @@ pub struct Files {
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Designs opened from the computer's disk: their paths in the engine's files.
pub opened: RefCell<Vec<String>>,
/// The ids of the files remembered from earlier sessions (see `disk::restore`).
pub disk_known: RefCell<std::collections::HashSet<String>>,
/// Recent designs whose file is gone: to drop from the recent list.
pub gone: RefCell<Vec<String>>,
/// Disk designs whose last write failed: they are not saved.
pub unsaved: RefCell<Vec<String>>,
/// Messages for the status line (errors and confirmations).
@@ -90,7 +94,7 @@ impl Files {
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
pub fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
@@ -267,6 +271,10 @@ pub fn start(files: &Rc<Files>) {
}
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
Ok(ids) => files.disk_known.borrow_mut().extend(ids),
Err(e) => log::info!("no remembered files: {e}"),
}
match read_all().await {
Ok(all) => {
if local_only() {
+19 -1
View File
@@ -136,6 +136,11 @@ mod web {
self.app.open_path(&path);
self.files.panel.set(false);
}
// Recent designs whose file is gone are dropped from the list.
for path in self.files.gone.borrow_mut().drain(..) {
self.app.home.recent.retain(|r| *r != path);
self.app.home.opened_at.remove(&path);
}
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
@@ -203,7 +208,9 @@ mod web {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
let known = self.files.disk_known.borrow().clone();
// A design on the computer's disk stays listed while its file is remembered.
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
@@ -211,6 +218,10 @@ mod web {
}
}
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
// Remembered files that no recent design or autosave names are forgotten in time.
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
disk::prune(keep);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -563,6 +574,7 @@ mod web {
let files = Rc::new(Files::default());
files::start(&files);
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = Services {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it); otherwise,
@@ -587,6 +599,12 @@ mod web {
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
}
})),
// A recent design on the disk, from an earlier session, opens by asking
// the browser for its remembered file.
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
download_design: Some(Box::new(files::download)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
install_app: Some(crate::install::service(&cc.egui_ctx)),
+2
View File
@@ -210,6 +210,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
can_restore: None,
restore_file: None,
download_design: None,
browser_files: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -348,11 +348,16 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_(p, "prefix").unwrap_or("");
// The identifier being typed: trailing letters, digits and underscores.
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&word.to_ascii_lowercase());
let lower = word.to_ascii_lowercase();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&lower);
// A parameter matches wherever the word appears in its name (`side` finds
// `cab_side_thickness`, #2757); functions, constants and units match from the start.
let within = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let mut out: Vec<Value> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| starts(&r.name)).collect();
// Favourites, then user parameters, then the rest.
rows.sort_by_key(|r| (!r.favorite, r.source != "user"));
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then the rest; within each, favourites, then user
// parameters, then the others.
rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user"));
for r in rows.into_iter().take(200) {
let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
let mut detail = format!("{} = {v}", r.expression);
+21
View File
@@ -212,6 +212,27 @@ fn favorites_comments_and_completion() {
assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}");
}
/// #2757: a parameter is suggested for a word anywhere in its name, not only at its start;
/// names that start with the word come first. Functions still match from the start.
#[test]
fn completion_matches_parameters_anywhere_in_their_name() {
let mut s = Session::default();
for (name, e) in [("cab_side_thickness", "18 mm"), ("side", "40 mm"), ("door_width", "300 mm")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": e}));
}
let texts = |c: &Value| -> Vec<String> {
c["suggestions"].as_array().unwrap().iter().filter(|x| x["kind"] == "parameter").map(|x| x["text"].as_str().unwrap().to_string()).collect()
};
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * side"}));
assert_eq!(texts(&c), ["side", "cab_side_thickness"], "the name starting with it first: {c}");
let c = run(&mut s, "parameters.complete", json!({"prefix": "THICK"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "any case: {c}");
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "xyz"}))).is_empty());
// Functions keep matching from the start: `in` is not `sin(`.
let c = run(&mut s, "parameters.complete", json!({"prefix": "in"}));
assert!(!c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "sin("), "{c}");
}
#[test]
fn csv_and_json_round_trip() {
let mut s = Session::default();
+7 -4
View File
@@ -2513,9 +2513,11 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let id = target_sketch(s, p, cmd)?;
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let (_, rep) = edit_solved(s, id, false, Analysis::Freedom, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false).1.solve_again()))
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false, last).1.solve_again()))
})?;
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
}
@@ -2538,7 +2540,8 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
};
let id = target_sketch(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let analysis = if live { Analysis::Full } else { Analysis::Freedom };
let (d, rep) = edit_solved(s, id, false, analysis, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
@@ -2565,7 +2568,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
}
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, &with, from, to, live);
let (d, left) = drag_entity(sk, e, &with, from, to, live, last);
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
// its degrees of freedom.
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
@@ -2574,7 +2577,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
// average displacement of their moving points, as before.
let sk = s.doc.sketch(id)?;
let at = d.at(sk, from);
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
+27 -1
View File
@@ -125,7 +125,33 @@ impl Path {
///
/// `live`: one frame of an interactive drag, whose held solves and cursor correction share
/// one frame's work budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, with: &[Ent], from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
///
/// `solved`: the sketch as last evaluated. When it is unchanged and solved, a grabbed entity it
/// determined is not dragged.
pub(super) fn drag_entity(
sk: &mut Sketch,
e: Ent,
with: &[Ent],
from: Vec2,
to: Vec2,
live: bool,
solved: Option<&solidcraft_doc::SolvedSketch>,
) -> (Dragged, Left) {
// An unchanged, solved entity with no freedom cannot follow a drag. Holding it at a
// different position only makes its constraints conflict, and a large pattern would spend
// minutes trying dense solves before returning to the same geometry (#2006).
if solved.is_some_and(|ss| {
ss.report.ok()
&& ss.sketch == *sk
&& match e {
Ent::Point(q) => ss.report.point_determined.get(q),
Ent::Curve(c) => ss.report.curve_determined.get(c),
}
.copied()
.unwrap_or(false)
}) {
return (Dragged::default(), Left::Solved);
}
let Some((path, hold, n, short)) = prepare(sk, e, with, from, to) else {
return (Dragged::default(), Left::Unsolved);
};
@@ -7,6 +7,52 @@ use solidcraft_geom::Vec3;
use super::solve_counts;
use crate::Session;
#[test]
fn determined_pattern_geometry_does_not_attempt_conflicting_held_solves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [6.5, 6.5]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 1.5}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 1.5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
let pattern = run(
&mut s,
"sketch.pattern.rectangular",
json!({"entities": ["l1", "l2", "l3", "l4"], "count": 3, "count2": 3, "spacing": 2.2, "spacing2": -2.2}),
);
let line = pattern["curves"][0].as_str().expect("a patterned line");
let endpoint = format!("{line}.start");
let before = sketch_of(&s);
assert!(!before.curve_locked(before.curve_index(line).expect("patterned line index")));
for entity in [line, endpoint.as_str()] {
for live in [true, false] {
reset(&mut s);
let result = run(&mut s, "sketch.drag", json!({"entity": entity, "from": [5, 5], "to": [9, 12], "live": live}));
assert_eq!(result["reached"], json!(0.0));
assert_eq!(result["at"], json!([5.0, 5.0]));
assert_eq!(result["sketch"]["solved"], json!(true));
let full = usize::from(!live);
assert_eq!(solve_counts::take(), (full, full), "no held solve for determined geometry");
assert_eq!(sketch_of(&s), before);
}
}
reset(&mut s);
run(&mut s, "sketch.move_point", json!({"point": endpoint, "to": [9, 12]}));
assert_eq!(solve_counts::take(), (1, 1), "move_point also skips held solves");
assert_eq!(sketch_of(&s), before);
// A cached determined report must not suppress an outstanding dimension edit.
let dimension = before
.constraints
.iter()
.find(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Length { l: 0, .. }))
.and_then(|c| c.param.as_ref())
.expect("the first line's dimension");
s.doc_mut().params.iter_mut().find(|p| &p.name == dimension).expect("dimension parameter").expr = "3".into();
run(&mut s, "sketch.drag", json!({"entity": line, "from": [5, 5], "to": [9, 12], "live": true}));
assert!((pt(&s, "l1.start").dist(pt(&s, "l1.end")) - 3.0).abs() < 1e-6);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
+34 -2
View File
@@ -504,11 +504,42 @@ impl Mesh {
if self.smooth_seam(e, cos) {
return chain;
}
// The edges by the grid cell of each end, so each step looks only at the edges that end
// near it (#3745: scanning every edge for every edge of a long helical chain held a hover
// for seconds). Cells are twice the join tolerance: two ends within it share a cell or
// touch neighbouring ones.
let cell = tol * 2.0;
let key = |p: Vec3| [(p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64];
let mut at_end: std::collections::HashMap<[i64; 3], Vec<usize>> = std::collections::HashMap::new();
for (i, p) in self.edges.iter().enumerate() {
for q in [p.first(), p.last()].into_iter().flatten().filter(|q| q.is_finite()) {
let slot = at_end.entry(key(*q)).or_default();
if slot.last() != Some(&i) {
slot.push(i);
}
}
}
let mut in_chain: std::collections::HashSet<usize> = chain.iter().copied().collect();
let mut todo = vec![e];
while let Some(cur) = todo.pop() {
let Some(ce) = ends(cur) else { continue };
for (i, _) in self.edges.iter().enumerate() {
if chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
// The edges with an end near one of `cur`'s, in edge order (as a scan of every edge
// meets them).
let mut near: Vec<usize> = Vec::new();
for (p, _) in &ce {
let [x, y, z] = key(*p);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
near.extend(at_end.get(&[x + dx, y + dy, z + dz]).into_iter().flatten());
}
}
}
}
near.sort_unstable();
near.dedup();
for i in near {
if in_chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
continue;
}
let Some(ne) = ends(i) else { continue };
@@ -516,6 +547,7 @@ impl Mesh {
let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos));
if smooth && chain.len() < 10_000 {
chain.push(i);
in_chain.insert(i);
todo.push(i);
}
}
+26
View File
@@ -164,6 +164,32 @@ fn tangent_chains_follow_smooth_edges() {
assert_eq!(seam.tangent_chain(0, 5f64.to_radians()), vec![0]);
}
/// #3745: the tangent chain of a long helix (a thread's crest, in many edge pieces) is found
/// without scanning every edge for every edge of it: hovering a threaded part's edge looked for
/// its rim this way, and took seconds inside the frame. Every piece joins, in edge order, and
/// a mesh of 3 000 pieces takes well under a frame.
#[test]
fn a_long_helical_tangent_chain_is_found_quickly() {
let pieces = 3_000usize;
let at = |t: f64| Vec3::new(4.0 * t.cos(), 4.0 * t.sin(), 0.2 * t);
let step = 0.05;
let mut m = Mesh::default();
for k in 0..pieces {
let t0 = k as f64 * step * 4.0;
m.edges.push((0..=4).map(|j| at(t0 + j as f64 * step)).collect());
m.edge_faces.push(vec![0]);
}
// The mesh's points (its size sets the join tolerance).
m.positions = m.edges.iter().flatten().copied().collect();
let began = std::time::Instant::now();
let chain = m.tangent_chain(pieces / 2, 2f64.to_radians());
let ms = began.elapsed().as_secs_f64() * 1000.0;
let mut sorted = chain.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..pieces).collect::<Vec<_>>(), "every piece of the helix is in the chain");
assert!(ms < 200.0, "{ms:.1} ms for {pieces} pieces");
}
#[test]
fn circular_rims_group_only_closed_cocircular_edges() {
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
+199 -25
View File
@@ -347,6 +347,11 @@ struct Nurbs {
impl Nurbs {
fn eval(&self, t: f64) -> Option<V3> {
let h = self.eval_h(t)?;
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
}
/// The homogeneous point (x·w, y·w, z·w, w) at `t`.
fn eval_h(&self, t: f64) -> Option<[f64; 4]> {
let p = self.deg;
let n = self.pts.len();
if n == 0 || self.knots.len() != n + p + 1 {
@@ -368,14 +373,49 @@ impl Nurbs {
d[j] = [0, 1, 2, 3].map(|c| (1.0 - alpha) * x[c] + alpha * y[c]);
}
}
let h = d[p];
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
d.get(p).copied()
}
fn range(&self) -> (f64, f64) {
(self.knots.get(self.deg).copied().unwrap_or(0.0), self.knots.get(self.pts.len()).copied().unwrap_or(0.0))
}
}
/// A circular arc in its definition plane (at height `c[2]`), counter-clockwise from `a0` to
/// `a1` (radians, `a1 > a0`).
struct Arc {
c: V3,
rad: f64,
a0: f64,
a1: f64,
}
impl Arc {
/// The arc placed by `x`, as an exact rational quadratic B-spline (pieces of at most a
/// quarter turn).
fn nurbs(&self, x: &Xf) -> Nurbs {
let sweep = self.a1 - self.a0;
let pieces = (sweep / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = sweep / pieces as f64;
let w = (step / 2.0).cos();
let on = |t: f64, k: f64| x.point([self.c[0] + k * t.cos(), self.c[1] + k * t.sin(), self.c[2]]);
let mut pts = Vec::with_capacity(2 * pieces + 1);
let mut knots = vec![0.0; 3];
for i in 0..pieces {
let t = self.a0 + step * i as f64;
let (p, m) = (on(t, self.rad), on(t + step / 2.0, self.rad / w));
pts.push([p[0], p[1], p[2], 1.0]);
pts.push([m[0] * w, m[1] * w, m[2] * w, w]);
if i > 0 {
knots.extend([i as f64 / pieces as f64; 2]);
}
}
let p = on(self.a1, self.rad);
pts.push([p[0], p[1], p[2], 1.0]);
knots.extend([1.0; 3]);
Nurbs { deg: 2, knots, pts }
}
}
struct Tr<'a> {
f: &'a Iges,
o: Out,
@@ -423,6 +463,11 @@ impl<'a> Tr<'a> {
}
/// The entity's own transformation (its chain), in millimetres.
fn own_xf(&self, seq: i64, depth: usize) -> R<Xf> {
self.own_xf_in(seq, depth, self.f.unit)
}
/// The entity's own transformation with translations in units of `unit` millimetres (1 for
/// a curve in a surface's parameter space, which has no length unit).
fn own_xf_in(&self, seq: i64, depth: usize, unit: f64) -> R<Xf> {
let de = self.de(seq)?;
if de.xform <= 0 || depth > MAX_DEPTH {
return Ok(Xf::ID);
@@ -437,13 +482,15 @@ impl<'a> Tr<'a> {
for (col, x) in r.iter_mut().enumerate() {
*x = self.n(t, 1 + row * 4 + col)?;
}
r[3] *= self.f.unit;
r[3] *= unit;
}
let parent = self.own_xf(t, depth + 1)?;
let parent = self.own_xf_in(t, depth + 1, unit)?;
Ok(parent.then(&Xf(m)))
}
fn pt(&self, seq: i64, i: usize) -> R<V3> {
let u = self.f.unit;
self.pt_in(seq, i, self.f.unit)
}
fn pt_in(&self, seq: i64, i: usize, u: f64) -> R<V3> {
Ok([self.n(seq, i)? * u, self.n(seq, i + 1)? * u, self.n(seq, i + 2)? * u])
}
/// A point entity (116) or direction (123), placed.
@@ -489,20 +536,8 @@ impl<'a> Tr<'a> {
Ok(vec![Seg::along(c, |t| add(a, mul(sub(b, a), t)))])
}
100 => {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
let Arc { c, rad, a0, a1 } = self.arc(seq)?;
let zt = c[2];
let at = |t: f64| x.point([c[0] + rad * t.cos(), c[1] + rad * t.sin(), zt]);
let z = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let xa = unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate arc frame")?;
@@ -571,7 +606,60 @@ impl<'a> Tr<'a> {
}
}
/// A circular arc (100) in its definition plane, in millimetres.
fn arc(&self, seq: i64) -> R<Arc> {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
Ok(Arc { c, rad, a0, a1 })
}
/// A surface of revolution (120) whose generatrix is the circular arc `seq`, about the axis
/// through `o` along `d`. A STEP surface of revolution turns the whole of its curve, and the
/// whole circle turns into a whole sphere or torus (a sphere twice over when the arc is
/// centred on the axis): the face's trim cannot pick its piece out of that, and the face
/// meshed as the full ball. An arc in a plane through the axis is a sphere or a torus; any
/// other arc turns as an exact rational B-spline of just the arc.
fn revolved_arc(&mut self, seq: i64, xf: &Xf, o: V3, d: V3) -> R<u64> {
let x = xf.then(&self.own_xf(seq, 0)?);
let arc = self.arc(seq)?;
let n = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let (centre, rad) = (x.point(arc.c), arc.rad * x.scale());
let off = sub(centre, o);
let h = dot(off, d);
let rho = norm(sub(off, mul(d, h)));
let tol = self.tol.max(rad * 1e-9);
if dot(n, d).abs() < 1e-7 && dot(off, n).abs() <= tol {
let ax = self.o.placement(add(o, mul(d, h)), d, perp(d));
if rho <= tol {
return Ok(self.o.add(format!("SPHERICAL_SURFACE('',#{ax},{})", r(rad))));
}
if rad < rho {
return Ok(self.o.add(format!("TOROIDAL_SURFACE('',#{ax},{},{})", r(rho), r(rad))));
}
}
let nb = arc.nurbs(&x);
let curve = self.write_nurbs_curve(&nb);
let (pa, dd) = (self.o.point(o), self.o.dir(d));
let a1 = self.o.add(format!("AXIS1_PLACEMENT('',#{pa},#{dd})"));
Ok(self.o.add(format!("SURFACE_OF_REVOLUTION('',#{curve},#{a1})")))
}
fn nurbs_curve(&self, seq: i64, x: &Xf) -> R<Nurbs> {
self.nurbs_curve_in(seq, x, self.f.unit)
}
fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R<Nurbs> {
let k = self.i(seq, 1)?;
let m = self.i(seq, 2)?;
if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 {
@@ -591,7 +679,7 @@ impl<'a> Tr<'a> {
if !(w > 0.0) {
return Err(format!("entity {seq}: non-positive weight"));
}
let p = x.point(self.pt(seq, base_p + 3 * j)?);
let p = x.point(self.pt_in(seq, base_p + 3 * j, unit)?);
pts.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
if knots.windows(2).any(|w| w[1] < w[0]) {
@@ -784,6 +872,9 @@ impl<'a> Tr<'a> {
let ax_x = x.then(&self.own_xf(axis, 0)?);
let (a, b) = (ax_x.point(self.pt(axis, 1)?), ax_x.point(self.pt(axis, 4)?));
let d = unit(sub(b, a)).ok_or("zero-length revolution axis")?;
if self.de(generatrix)?.typ == 100 {
return self.revolved_arc(generatrix, &x, a, d);
}
let segs = self.curve(generatrix, &x, 1)?;
let [seg] = segs.as_slice() else { return Err(format!("entity {seq}: composite generatrix is not read")) };
let (pa, dd) = (self.o.point(a), self.o.dir(d));
@@ -806,6 +897,61 @@ impl<'a> Tr<'a> {
}
}
/// The start point of a curve in a surface's parameter space (the B curve of a curve on a
/// surface, 142): its (u, v) are the first two coordinates. Lines, arcs, B-splines and
/// composites of them, as the specification lists for parameter-space curves.
fn param_start(&self, seq: i64, depth: usize) -> R<V3> {
if depth > MAX_DEPTH {
return Err("parameter-space curves nested too deeply".into());
}
let de = self.de(seq)?;
let x = self.own_xf_in(seq, 0, 1.0)?;
let p = match de.typ {
110 => self.pt_in(seq, 1, 1.0)?,
100 => [self.n(seq, 4)?, self.n(seq, 5)?, self.n(seq, 1)?],
126 => {
let nb = self.nurbs_curve_in(seq, &Xf::ID, 1.0)?;
nb.eval(nb.range().0).ok_or_else(|| format!("entity {seq}: B-spline cannot be evaluated"))?
}
102 if self.i(seq, 1)? > 0 => self.param_start(self.i(seq, 2)?, depth + 1)?,
t => return Err(format!("parameter-space curve type {t} is not read")),
};
Ok(x.point(p))
}
/// The point of a B-spline surface (128) at parameters (u, v), placed as `surface` places it.
fn surface_point(&self, seq: i64, xf: &Xf, u: f64, v: f64) -> R<V3> {
if self.de(seq)?.typ != 128 {
return Err(format!("entity {seq}: only B-spline surfaces are evaluated"));
}
let x = xf.then(&self.own_xf(seq, 0)?);
let (k1, k2, m1, m2) = (self.i(seq, 1)?, self.i(seq, 2)?, self.i(seq, 3)?, self.i(seq, 4)?);
if !(0..=4096).contains(&k1) || !(0..=4096).contains(&k2) || !(1..=32).contains(&m1) || !(1..=32).contains(&m2) {
return Err(format!("entity {seq}: bad B-spline surface size"));
}
let (k1, k2, m1, m2) = (k1 as usize, k2 as usize, m1 as usize, m2 as usize);
let (n1, n2) = (k1 + m1 + 2, k2 + m2 + 2);
let s_knots: Vec<f64> = (0..n1).map(|j| self.n(seq, 10 + j)).collect::<R<_>>()?;
let t_knots: Vec<f64> = (0..n2).map(|j| self.n(seq, 10 + n1 + j)).collect::<R<_>>()?;
let base_w = 10 + n1 + n2;
let base_p = base_w + (k1 + 1) * (k2 + 1);
// Each row of the net at u, then the column of those points at v (homogeneous, so the
// weights combine as the rational tensor product does).
let mut column = Vec::with_capacity(k2 + 1);
for j in 0..=k2 {
let mut row = Vec::with_capacity(k1 + 1);
for i in 0..=k1 {
let idx = j * (k1 + 1) + i;
let w = self.n(seq, base_w + idx)?;
let p = x.point(self.pt(seq, base_p + 3 * idx)?);
row.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
let h = Nurbs { deg: m1, knots: s_knots.clone(), pts: row }.eval_h(u).ok_or("surface cannot be evaluated")?;
column.push(h);
}
Nurbs { deg: m2, knots: t_knots, pts: column }.eval(v).ok_or_else(|| "surface cannot be evaluated".into())
}
/// The natural boundary of a B-spline surface (its four edge curves), for trimmed surfaces
/// with no outer curve.
fn natural_boundary(&mut self, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
@@ -1197,10 +1343,10 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
let n1 = t.i(seq, 2)?;
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
let mut loops = Vec::new();
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf)? };
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
loops.push(loop_of(t, sw, outer));
for k in 0..n2 {
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf)?;
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
loops.push(loop_of(t, sw, segs));
}
loops.retain(|l| !l.is_empty());
@@ -1234,8 +1380,16 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
}
}
/// The model-space curve of a curve on a surface (142).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
/// The model-space curve of a curve on a surface (142) bounding a trimmed `surface`, its pieces
/// head to tail.
///
/// A curve on a surface is one curve given twice: B in the surface's parameter space and C in
/// model space, both running the same way (C = S∘B). A composite C must run each piece on from
/// the end of the one before, but some exporters write its pieces in order with some of them
/// reversed, so the loop does not close and the face is lost. The pieces are turned to run on,
/// starting where S(B) starts (the end of the first piece there), so the loop also keeps the
/// sense the file gives it (outer loop and holes the opposite ways round).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf, surface: i64) -> R<Vec<Seg>> {
let de = t.de(seq)?;
if de.typ != 142 {
return t.curve(seq, xf, 0);
@@ -1245,7 +1399,27 @@ fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
if c <= 0 {
return Err("a curve on a surface without its model-space curve".into());
}
t.curve(c, &x, 0)
let segs = t.curve(c, &x, 0)?;
let b = t.i(seq, 3).unwrap_or(0);
let start = if b > 0 { t.param_start(b, 0).and_then(|p| t.surface_point(surface, xf, p[0], p[1])).ok() } else { None };
Ok(head_to_tail(segs, start, t.tol))
}
/// Pieces of a composite curve turned so that each starts where the one before ends, the first
/// at the end nearer `start` (when known). A closed piece (its ends within `tol`, the sewing
/// tolerance that decides they are one vertex) keeps its direction.
fn head_to_tail(segs: Vec<Seg>, start: Option<V3>, tol: f64) -> Vec<Seg> {
let mut prev = start;
segs.into_iter()
.map(|s| {
let s = match prev {
Some(p) if norm(sub(s.a, s.b)) > tol && norm(sub(s.b, p)) < norm(sub(s.a, p)) => s.reversed(),
_ => s,
};
prev = Some(s.b);
s
})
.collect()
}
/// The model-space curves of a boundary (141), each in its sense.
+136 -3
View File
@@ -85,10 +85,16 @@ fn hostile_iges_never_panics() {
/// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1).
fn iges_file(ents: &[(i64, i64, String)]) -> String {
let placed: Vec<(i64, i64, String, i64)> = ents.iter().map(|(t, f, p)| (*t, *f, p.clone(), 0)).collect();
iges_file_placed(&placed)
}
/// Like [`iges_file`], with each entity's transformation matrix pointer (0 for none).
fn iges_file_placed(ents: &[(i64, i64, String, i64)]) -> String {
let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)];
let mut p_lines: Vec<(usize, String)> = Vec::new();
let mut starts = Vec::new();
for (k, (t, _, p)) in ents.iter().enumerate() {
for (k, (t, _, p, _)) in ents.iter().enumerate() {
starts.push(p_lines.len() + 1);
let text = format!("{t},{p};");
let chars: Vec<char> = text.chars().collect();
@@ -96,9 +102,9 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
p_lines.push((2 * k + 1, c.iter().collect()));
}
}
for (k, (t, form, _)) in ents.iter().enumerate() {
for (k, (t, form, _, xf)) in ents.iter().enumerate() {
let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count();
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, xf, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2));
}
for (i, (d, l)) in p_lines.iter().enumerate() {
@@ -108,6 +114,48 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
out.join("\n")
}
/// A spherical cap written the way analytic IGES exports write one: a surface of revolution
/// (120) whose generatrix is a short circular arc (100) centred on the axis, turned into the
/// meridian plane by its transformation matrix (124), trimmed by a circle round the axis. The arc
/// is only the part of the circle the cap needs; read as the whole circle, the revolution was a
/// whole sphere twice over and the cap meshed as the full ball.
#[test]
fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
let (r, polar) = (50.0f64, 30f64.to_radians());
let (rim_r, rim_z) = (r * polar.sin(), r * polar.cos());
let ents: Vec<(i64, i64, String, i64)> = vec![
// 1: the meridian plane: local x stays x, local y becomes z.
(124, 0, "1.,0.,0.,0.,0.,0.,-1.,0.,0.,1.,0.,0.".into(), 0),
// 3: the axis, 5: the arc from the rim (60° in its plane) to the pole (90°).
(110, 0, "0.,0.,0.,0.,0.,1.".into(), 0),
(100, 0, format!("0.,0.,0.,{:?},{:?},0.,{r:?}", rim_r, rim_z), 1),
// 7: the surface, 9: the rim (a full circle at the cap's height), 11: on the surface.
(120, 0, format!("3,5,0.,{:?}", std::f64::consts::TAU), 0),
(100, 0, format!("{rim_z:?},0.,0.,{rim_r:?},0.,{rim_r:?},0."), 0),
(142, 0, "0,7,0,9,2".into(), 0),
// 13: the cap.
(144, 0, "7,1,0,11".into(), 0),
];
let imp = iges_import(&iges_file_placed(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let mesh = imp.bodies[0].body.tessellate(0.01).unwrap();
let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);
for p in &mesh.positions {
for (k, x) in [p.x, p.y, p.z].into_iter().enumerate() {
lo[k] = lo[k].min(x);
hi[k] = hi[k].max(x);
}
}
for k in 0..2 {
assert!((lo[k] + rim_r).abs() < 0.1 && (hi[k] - rim_r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
}
assert!((lo[2] - rim_z).abs() < 0.1 && (hi[2] - r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
// Area of a cap: 2π r h.
let area = measure(&imp.bodies[0].body).unwrap().area;
let cap = std::f64::consts::TAU * r * (r - rim_z);
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
#[test]
@@ -209,3 +257,88 @@ fn directory_entries_cannot_share_their_way_past_the_file_size() {
assert!(error.contains("parameter data is too large"), "{error}");
assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed());
}
/// A curve on a surface (142) for a trimmed patch: model-space lines through `corners` with the
/// pieces `reversed` written end to start (as some exporters write composite trim curves), and
/// the same loop in the patch's parameter space through `uv`. Returns the 142's pointer.
fn trim_loop(ents: &mut Vec<(i64, i64, String)>, surface: usize, corners: &[[f64; 3]], uv: &[[f64; 2]], reversed: &[usize]) -> usize {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
let mut model = Vec::new();
let mut param = Vec::new();
for k in 0..corners.len() {
let (p0, p1) = (corners[k], corners[(k + 1) % corners.len()]);
let (p0, p1) = if reversed.contains(&k) { (p1, p0) } else { (p0, p1) };
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
model.push(2 * ents.len() - 1);
let (q0, q1) = (uv[k], uv[(k + 1) % uv.len()]);
ents.push((110, 0, format!("{},{}", fmt([q0[0], q0[1], 0.0]), fmt([q1[0], q1[1], 0.0]))));
param.push(2 * ents.len() - 1);
}
let list = |v: &[usize]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",");
ents.push((102, 0, format!("{},{}", model.len(), list(&model))));
let c = 2 * ents.len() - 1;
ents.push((102, 0, format!("{},{}", param.len(), list(&param))));
let b = 2 * ents.len() - 1;
// Created by projection, preferring the parameter-space curve (PREF 1).
ents.push((142, 0, format!("0,{surface},{b},{c},1")));
2 * ents.len() - 1
}
/// A bilinear B-spline patch through corners `a` (u, v = 0), `b` (u = 1), `c` (u, v = 1) and
/// `d` (v = 1).
fn bilinear_patch(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> (i64, i64, String) {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
(128, 0, format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(c)))
}
/// The box of `trimmed_surfaces_sew_into_a_solid` with some pieces of every trim curve written
/// end to start (the first piece on half the faces): each face's loop still closes and the box
/// is a solid.
#[test]
fn trim_curves_with_reversed_pieces_still_close_their_faces() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
for (fi, q) in quads.iter().enumerate() {
let corners = q.map(c);
ents.push(bilinear_patch(corners[0], corners[1], corners[2], corners[3]));
let surface = 2 * ents.len() - 1;
let reversed: &[usize] = if fi % 2 == 0 { &[0, 2] } else { &[1, 3] };
let cos = trim_loop(&mut ents, surface, &corners, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], reversed);
ents.push((144, 0, format!("{surface},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
/// A plate with a square hole whose trim curve starts with a reversed piece: where the
/// parameter-space curve starts decides which way the hole runs, so it stays a hole.
#[test]
fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() {
let mut ents: Vec<(i64, i64, String)> = vec![bilinear_patch([0.0; 3], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0])];
let outer = trim_loop(
&mut ents,
1,
&[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]],
&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
&[],
);
// Clockwise about the patch's normal, as a hole runs.
let hole = trim_loop(
&mut ents,
1,
&[[3.0, 3.0, 0.0], [3.0, 7.0, 0.0], [7.0, 7.0, 0.0], [7.0, 3.0, 0.0]],
&[[0.3, 0.3], [0.3, 0.7], [0.7, 0.7], [0.7, 0.3]],
&[0],
);
ents.push((144, 0, format!("1,1,1,{outer},{hole}")));
let imp = iges_import(&iges_file(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1);
assert!(!imp.warnings.iter().any(|w| w.contains("not read") || w.contains("face #")), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area);
}
+4 -6
View File
@@ -307,8 +307,8 @@ pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
/// The eight radial commands, clockwise from the top.
pub fn radial_items(app: &SolveApp) -> Vec<Item> {
let repeat = match &app.last_command {
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo"),
None => act("ui.repeat", "Repeat", "redo").on(false),
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY),
None => act("ui.repeat", "Repeat", "redo").key(crate::keymap::REPEAT_KEY).on(false),
};
let delete = act("ui.delete", "Delete", "delete").key("Del").on(!app.session.selection.is_empty());
if app.session.active_sketch.is_some() {
@@ -464,7 +464,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
let has = |f: fn(&Sel) -> bool| sel.iter().any(f);
let mut v = Vec::new();
if let Some((_, label)) = &app.last_command {
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY));
v.push(Item::sep());
}
// Everything under the cursor, for what lies beneath something else.
@@ -975,9 +975,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.repeat" => {
if let Some((id, _)) = app.last_command.clone() {
app.start(&id);
}
app.repeat_last();
}
"ui.selectOther" => {
crate::select_other::open(app, at);
+3 -2
View File
@@ -164,7 +164,7 @@ pub fn with_symbol_fallback(mut fonts: egui::FontDefinitions) -> egui::FontDefin
/// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only;
/// add Chinese or Korean here together with a font that covers them.
pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "ko"];
pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "zh-TW", "ko"];
/// Whether the UI language needs a CJK font (the web app fetches one only then).
pub fn needs_cjk_font(language: &str) -> bool {
@@ -648,7 +648,7 @@ fn find_system_cjk_font() -> Option<Vec<u8>> {
/// Thai comes last whatever the language: no other face draws it.
pub fn fallback_order(language: &str) -> [&'static str; 4] {
match language {
"zh" => [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
"zh" | "zh-TW" => [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
"ko" => [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK],
_ => [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
}
@@ -991,6 +991,7 @@ mod tests {
#[test]
fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() {
assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]);
assert_eq!(fallback_order("zh-TW"), fallback_order("zh"));
assert_eq!(fallback_order("ko"), [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK]);
for lang in ["en", "ja", "de"] {
assert_eq!(
+31 -1
View File
@@ -425,8 +425,17 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
}
/// Is this recent design there to open: in memory, or something the host can bring back?
pub fn available(app: &SolveApp, path: &str) -> bool {
solidcraft_engine::io::vfs::exists(path) || app.services.can_restore.as_ref().is_some_and(|can| can(path))
}
pub fn open_recent(app: &mut SolveApp, path: &str) {
if !solidcraft_engine::io::vfs::exists(path) {
if let Some(restore) = app.services.restore_file.as_ref().filter(|_| available(app, path)) {
restore(path);
return;
}
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -529,9 +538,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(p) = recent.get(i) else { return };
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let missing = !solidcraft_engine::io::vfs::exists(p);
let missing = !available(app, p);
let mut sub = if missing {
app.tr("home.missing").to_string()
} else if p.starts_with("/disk/") {
// The web build remembers files it saved on the computer.
crate::i18n::tr_label("On your computer").to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
app.tr("home.in_this_browser").to_string()
@@ -695,6 +707,24 @@ mod tests {
assert!(sample_picture(samples.len()).is_none());
}
/// A recent design the host can bring back (the browser build's remembered files) is not
/// "missing": choosing it asks the host to restore it, and one the host doesn't know still is.
#[test]
fn a_recent_design_the_host_can_restore_is_asked_for_not_reported_missing() {
let mut h = crate::scenario::Harness::new();
let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
let sink = asked.clone();
h.app.services.can_restore = Some(Box::new(|p| p.starts_with("/disk/known/")));
h.app.services.restore_file = Some(Box::new(move |p| sink.borrow_mut().push(p.to_string())));
assert!(available(&h.app, "/disk/known/Bracket.solidcraft"));
assert!(!available(&h.app, "/disk/other/Bracket.solidcraft"));
open_recent(&mut h.app, "/disk/known/Bracket.solidcraft");
assert_eq!(*asked.borrow(), ["/disk/known/Bracket.solidcraft"]);
open_recent(&mut h.app, "/disk/other/Bracket.solidcraft");
assert_eq!(asked.borrow().len(), 1, "a file the host doesn't remember is not asked for");
assert!(h.app.status.as_ref().is_some_and(|(text, _, error)| *error && text.contains("no longer there")));
}
/// Rebuild the shipped pictures from the authoritative sample command scripts.
#[cfg(not(target_arch = "wasm32"))]
#[test]
+36
View File
@@ -38,18 +38,26 @@ static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!
static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json")));
static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json")));
static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/gl.json")));
static HUNGARIAN: Catalog = Catalog::new("hu", "Magyar", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/hu.json")));
static INDONESIAN: Catalog =
Catalog::new("id", "Bahasa Indonesia", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/id.json")));
static ITALIAN: Catalog = Catalog::new("it", "Italiano", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/it.json")));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json")));
static MALAY: Catalog = Catalog::new("ms", "Bahasa Melayu", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ms.json")));
static DUTCH: Catalog = Catalog::new("nl", "Nederlands", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/nl.json")));
static POLISH: Catalog = Catalog::new("pl", "Polski", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pl.json")));
static PORTUGUESE_BR: Catalog =
Catalog::new("pt-BR", "Português (Brasil)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pt-BR.json")));
static ROMANIAN: Catalog = Catalog::new("ro", "Română", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ro.json")));
static RUSSIAN: Catalog = Catalog::new("ru", "Русский", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ru.json")));
static SWEDISH: Catalog = Catalog::new("sv", "Svenska", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/sv.json")));
static TURKISH: Catalog = Catalog::new("tr", "Türkçe", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/tr.json")));
static UKRAINIAN: Catalog = Catalog::new("uk", "Українська", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/uk.json")));
static VIETNAMESE: Catalog = Catalog::new("vi", "Tiếng Việt", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/vi.json")));
static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh.json")));
static CHINESE_TRADITIONAL: Catalog =
Catalog::new("zh-TW", "繁體中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh-TW.json")));
/// English first (the fallback), then by language id, one per line so that adding a language
/// doesn't edit a line another addition edits.
#[rustfmt::skip]
@@ -61,17 +69,23 @@ pub const LOCALES: &[&Catalog] = &[
&SPANISH,
&FRENCH,
&GALICIAN,
&HUNGARIAN,
&INDONESIAN,
&ITALIAN,
&JAPANESE,
&KOREAN,
&MALAY,
&DUTCH,
&POLISH,
&PORTUGUESE_BR,
&ROMANIAN,
&RUSSIAN,
&SWEDISH,
&TURKISH,
&UKRAINIAN,
&VIETNAMESE,
&CHINESE,
&CHINESE_TRADITIONAL,
];
pub fn is_supported(id: &str) -> bool {
@@ -715,6 +729,28 @@ mod tests {
assert_eq!(vi.dynamic("Sketch of bracket"), "Sketch of bracket");
}
#[test]
fn new_languages_translate_keys_labels_and_default_names() {
for (id, name, preferences, extrude, sketch1, save) in [
("hu", "Magyar", "Beállítások", "Kihúzás", "Vázlat1", "Menti a(z) My {design} módosításait?"),
("id", "Bahasa Indonesia", "Preferensi", "Ekstrusi", "Sketsa1", "Simpan perubahan ke My {design}?"),
("nl", "Nederlands", "Voorkeuren", "Extruderen", "Schets1", "Wijzigingen in My {design} opslaan?"),
("ro", "Română", "Preferințe", "Extrudare", "Schița1", "Salvați modificările în My {design}?"),
("sv", "Svenska", "Inställningar", "Extrudera", "Skiss1", "Spara ändringar i My {design}?"),
("zh-TW", "繁體中文", "偏好設定", "擠出", "草圖1", "儲存對 My {design} 的變更?"),
] {
let locale = Locale::for_language(id);
assert_eq!(locale.name(), name);
assert!(is_supported(id));
assert_eq!(locale.text("ui.preferences"), preferences);
assert_eq!(locale.label("Extrude"), extrude);
assert_eq!(locale.label("NonExistentKey"), "NonExistentKey");
assert_eq!(locale.format("document.save_changes", &[("name", "My {design}")]), save);
assert_eq!(locale.dynamic("Sketch1"), sketch1);
assert_eq!(locale.dynamic("Sketch of bracket"), "Sketch of bracket");
}
}
#[test]
fn render_scopes_restore_the_previous_language() {
let _english = scope("en");
+31
View File
@@ -11,8 +11,12 @@ use solidcraft_engine::command_specs;
use crate::SolveApp;
use crate::ui_kit;
/// Repeats the last tool or dialog started (`SolveApp::repeat_last`, #3045).
pub const REPEAT_KEY: &str = "Shift+R";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
REPEAT_KEY,
"Escape",
"S",
"V",
@@ -379,6 +383,33 @@ mod tests {
assert_eq!(effective(&b, "solid.move").as_deref(), Some("M"));
assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K");
}
/// #3045: Shift+R starts the last tool again (here a sketch's Line after Escape put it
/// down), R alone is still the rectangle; the menus show the key on Repeat, and no command
/// can take it.
#[test]
fn shift_r_repeats_the_last_tool() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.frames(2);
h.step(&json!({"start": "sketch.line"})).unwrap();
h.step(&json!({"key": "Escape"})).unwrap();
h.frames(1);
assert!(h.app.tool.is_none());
h.step(&json!({"key": "R", "shift": true})).unwrap();
h.frames(1);
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd.to_string()).as_deref(), Some("sketch.line"), "Shift+R starts Line again");
h.step(&json!({"key": "Escape"})).unwrap();
h.step(&json!({"key": "R"})).unwrap();
h.frames(1);
let rectangle = h.app.tool.as_ref().map(|t| t.cmd.to_string()).unwrap_or_default();
assert!(rectangle.starts_with("sketch.rectangle"), "R alone: {rectangle}");
let repeat = crate::context_menu::radial_items(&h.app).into_iter().find(|i| i.id == "ui.repeat").unwrap();
assert_eq!(repeat.shortcut, REPEAT_KEY);
let mut a = app();
assert!(bind(&mut a, "solid.extrude", REPEAT_KEY, true).is_err(), "Shift+R is the app's");
}
}
#[cfg(test)]
+14
View File
@@ -332,6 +332,12 @@ pub struct Services {
/// Hand a design's bytes to the user as a downloaded file (name, bytes); the browser build's
/// File > Download design. None: the host saves real files, so there is nothing to offer.
pub download_design: Option<Box<dyn Fn(&str, &[u8]) -> Result<(), String>>>,
/// Whether a recent design that is not in memory can be brought back by the host (the browser
/// build remembers files it saved to the disk); None: it can't.
pub can_restore: Option<Box<dyn Fn(&str) -> bool>>,
/// Bring such a design back and open it when it arrives (asks the user for access, so call it
/// in the click that chose it).
pub restore_file: Option<Box<dyn Fn(&str)>>,
/// Show the designs the browser keeps for itself; None: the host has no such store.
pub browser_files: Option<Box<dyn Fn()>>,
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints); None: no API agents.
@@ -810,6 +816,14 @@ impl SolveApp {
workspace::sync_context(self);
}
/// Start the last tool or dialog started again (Repeat in the menus, Shift+R; #3045).
/// `false` when none was started yet.
pub fn repeat_last(&mut self) -> bool {
let Some((id, _)) = self.last_command.clone() else { return false };
self.start(&id);
true
}
/// Start a command the way a toolbar click does: interactive tools and dialogs for commands
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
+22 -5
View File
@@ -634,11 +634,12 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -
return Vec::new();
}
let w = word.to_ascii_lowercase();
all.iter()
.filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && (explicit || s.text.trim_end_matches('(') != word))
.take(8)
.cloned()
.collect()
let offered = |s: &&Suggestion| explicit || s.text.trim_end_matches('(') != word;
let starts = |s: &&Suggestion| s.text.to_ascii_lowercase().starts_with(&w);
// A parameter is also offered for a word anywhere in its name (`side` finds
// `cab_side_thickness`, #2757), after the names that start with it.
let within = |s: &&Suggestion| s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect()
}
/// Offer keyboard and pointer completions under a focused expression box.
@@ -672,6 +673,22 @@ mod tests {
assert!(check(&mut app, "width + 1 deg", "mm").is_err());
}
/// #2757: typing part of a parameter's name offers it, not only its start; names that start
/// with the word come first, and functions still match from the start.
#[test]
fn a_parameter_is_offered_for_any_part_of_its_name() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for (name, e) in [("cab_side_thickness", "18 mm"), ("sides", "4")] {
let _ = app.run("parameters.add", json!({"name": name, "expression": e}));
}
publish(&mut app, &ctx);
let texts = |w: &str| suggestions(&ctx, w).into_iter().map(|s| s.text).collect::<Vec<_>>();
assert_eq!(texts("side"), ["sides", "cab_side_thickness"]);
assert_eq!(texts("Thick"), ["cab_side_thickness"]);
assert!(!texts("in").iter().any(|t| t == "sin("), "functions match from the start");
}
/// A favourite is listed twice, under Favorites and in its own table; typing into either of its
/// expression boxes enters each key once (#1767: `1.1` came out `1.11.1`).
#[test]
+4 -1
View File
@@ -503,7 +503,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
crate::ui_kit::Menu::button(ui, app.tr("ui.open_recent"), |ui| {
for path in app.home.recent.clone() {
let missing = !solidcraft_engine::io::vfs::exists(&path);
let missing = !crate::home::available(app, &path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if crate::context_menu::button(ui, &label, "open").clicked() {
crate::home::open_recent(app, &path);
@@ -1845,6 +1845,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.start("ui.palette");
}
Key::V => crate::context_menu::toggle_visibility(app),
Key::R if mods.shift && !mods.alt => {
app.repeat_last();
}
Key::F2 => {
let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center());
crate::context_menu::rename_selection(app, at);
+19 -1
View File
@@ -7,6 +7,12 @@
# compatibility alias) with the licence files
#
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
# packaging/macos/package.sh --build-only <rust-target>
#
# --build-only builds just that target (aarch64-apple-darwin or x86_64-apple-darwin) and stops,
# so the release workflow can build the two halves of the universal binary on separate runners;
# a later `--arch universal --skip-build` run joins the binaries it finds under
# $CARGO_TARGET_DIR/<target>/release/ and packages them.
#
# The app is ad-hoc signed (not notarized): the first launch needs right-click > Open.
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
@@ -18,11 +24,13 @@ MACOS_BUNDLE_ID=ai.storyteller.solidcraft
ARCH=universal
SKIP_BUILD=0
BUILD_ONLY=
while [ $# -gt 0 ]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,10p' "$0"; exit 0 ;;
--build-only) BUILD_ONLY="$2"; shift 2 ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -41,6 +49,16 @@ APP="$WORK/SolidCraft.app"
DMG="$DIST/solidcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/solidcraft-cli-$VERSION-macos-$ARCH.zip"
if [ -n "$BUILD_ONLY" ]; then
case "$BUILD_ONLY" in
aarch64-apple-darwin | x86_64-apple-darwin) ;;
*) echo "unknown --build-only target $BUILD_ONLY" >&2; exit 2 ;;
esac
echo "==> SolidCraft $VERSION for macOS ($BUILD_ONLY, build only)"
(cd "$ROOT" && cargo build --release --locked -p solidcraft -p solidcraft-cli --target "$BUILD_ONLY")
exit 0
fi
echo "==> SolidCraft $VERSION for macOS ($ARCH)"
if [ "$SKIP_BUILD" = 0 ]; then
args=()